简介:这是一套面向高校计算机相关专业学生与Java Web初学者、可用于课程设计或毕业设计参考的企业仓库存储管理系统源码,采用Spring Boot+Java+Maven+MySQL+MyBatis技术栈,基于B/S结构开发,开发工具为IntelliJ IDEA。系统功能覆盖客户、仓库、产品三类基本信息管理,以及用户权限管理、入库与出库记录管理、库存管理和系统日志查看,基本满足仓储业务的核心流程。资源包共221个文件,约6.95MB,其中42个Java源文件承载后端业务逻辑,28个HTML页面与24个JavaScript脚本、8个CSS样式构成前端界面,另有1个SQL脚本用于数据库建表,并包含gif、jpg、png等图片素材及字体文件,目录结构清晰,便于按模块阅读与二次开发。目前已有568人学习下载,适合需要完整项目案例、想理解SSM/Spring Boot整合思路与仓储业务建模的读者参考借鉴。
1. 企业仓库存储管理系统:从 Excel 台账到 Web 应用的落地路径
很多中小企业的仓库管理还停留在 Excel 台账阶段,入库靠手填、出库靠翻记录、月底盘点对不上账是常态。企业仓库存储管理系统要解决的核心问题就一个:让每一次物料的进出都有据可查、有数可算、有责可追。这个标题里的 Java + MySQL + Web 三个词,指向的是一套典型的 B/S 架构方案——后端用 Java 处理业务逻辑,MySQL 做数据持久化,浏览器端完成操作交互。它适合两类人:一类是刚学完 JavaWeb 想做完整项目练手的开发者,另一类是企业里被 Excel 折磨到想自己搭一套轻量系统的 IT 负责人。这套系统不追求 SAP 那种体量,但库存实时更新、出入库单据流转、多角色权限控制这些核心能力一个不少。下面从表结构设计一路讲到部署上线,把我在实际搭建中踩过的坑和验证过的参数都摊开说。
2. 数据库表结构设计:库存字段和单据关联怎么定
2.1 核心表清单与字段规划
仓库系统的数据模型不复杂,但字段设计直接决定后面业务代码好不好写。我一般会先定五张核心表:物料表、仓库表、库存表、入库单表、出库单表。物料表存的是"东西是什么",库存表存的是"东西在哪里、有多少",单据表存的是"东西怎么进出的"。这三层分开,后面做库存流水和盘点才不会乱。
物料表t_material的关键字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| material_code | varchar(32) | 物料编码,唯一索引 |
| material_name | varchar(128) | 物料名称 |
| spec | varchar(64) | 规格型号 |
| unit | varchar(16) | 计量单位 |
| category_id | int | 分类 ID |
| safety_stock | int | 安全库存阈值 |
| create_time | datetime | 创建时间 |
库存表t_stock的设计有个关键决策点:要不要把物料和仓库的组合做成唯一索引。我的做法是加UNIQUE KEY uk_material_warehouse (material_id, warehouse_id),这样后面用INSERT ... ON DUPLICATE KEY UPDATE做库存增减时不会产生重复行。库存数量字段用int还是decimal取决于物料是否按重量管理,如果只是计件,int够用;如果有散装物料,用decimal(12,2)。
单据表t_inbound_order和t_outbound_order结构类似,主表存单头和状态,明细表存每一行物料。状态字段用tinyint而不是字符串,0 表示待审核、1 表示已审核、2 表示已入库/出库、3 表示已作废。这个状态机后面在 Java 代码里要用枚举对应,不要直接在 SQL 里写魔法数字。
2.2 建表 SQL 与索引策略
-- 物料表 CREATE TABLE t_material ( id BIGINT NOT NULL AUTO_INCREMENT, material_code VARCHAR(32) NOT NULL COMMENT '物料编码', material_name VARCHAR(128) NOT NULL COMMENT '物料名称', spec VARCHAR(64) DEFAULT NULL COMMENT '规格型号', unit VARCHAR(16) DEFAULT '个' COMMENT '计量单位', category_id INT DEFAULT NULL, safety_stock INT DEFAULT 0 COMMENT '安全库存', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_code (material_code), KEY idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物料主数据'; -- 库存表 CREATE TABLE t_stock ( id BIGINT NOT NULL AUTO_INCREMENT, material_id BIGINT NOT NULL, warehouse_id INT NOT NULL, quantity INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_material_warehouse (material_id, warehouse_id), KEY idx_warehouse (warehouse_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表'; -- 入库单主表 CREATE TABLE t_inbound_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '入库单号', warehouse_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1已审 2已入库 3作废', operator_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库单主表';这里有个血泪经验:t_stock的uk_material_warehouse唯一索引一定要在建表时就加上。我见过有项目先跑起来再补索引,结果历史数据里已经存在同一物料同一仓库的多条记录,补索引直接失败,还得写脚本合并数据。另外order_no的生成规则建议用"前缀 + 日期 + 序列"的格式,比如IN20250115001,不要用 UUID,否则对账时人眼没法看。
索引方面,t_inbound_order的idx_status和idx_create_time在列表查询时很关键。如果查询条件是"待审核的单据按时间倒序",MySQL 会优先走idx_status,然后回表排序。数据量超过十万行时,可以考虑建联合索引idx_status_create (status, create_time),让排序也在索引里完成。
3. Java 后端实现:Spring Boot 整合 MyBatis 的库存增减逻辑
3.1 项目分层与依赖配置
后端用 Spring Boot + MyBatis 的组合,这是目前 JavaWeb 项目里最稳的搭配。Spring Boot 版本选 2.7.x 或 3.x 都行,但要注意 3.x 要求 JDK 17,如果团队还在用 JDK 8,就锁在 2.7.x。MyBatis 用mybatis-spring-boot-starter,不要用 MyBatis-Plus 的重度封装,库存扣减这种场景需要精确控制 SQL,原生 MyBatis 的 XML 映射更透明。
pom.xml核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>application.yml里数据源配置有个容易翻车的点:serverTimezone必须显式指定,否则 MySQL 8 驱动会报时区错误。连接池用 HikariCP,maximum-pool-size设 10 到 20 之间,小企业仓库系统并发不高,设太大反而浪费连接资源。
spring: datasource: url: jdbc:mysql://localhost:3306/warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password hikari: maximum-pool-size: 15 minimum-idle: 5 connection-timeout: 300003.2 库存扣减的 SQL 与事务控制
库存扣减是整个系统里最容易出并发问题的地方。常见做法是在 Service 层加@Transactional,然后在 Mapper 里用一条带条件的 UPDATE 语句完成扣减,而不是先 SELECT 再 UPDATE。后者在并发场景下会出现超卖,这是电商和仓库系统里最经典的坑。
// StockMapper.java @Mapper public interface StockMapper { // 扣减库存:只有当剩余数量足够时才更新 @Update("UPDATE t_stock SET quantity = quantity - #{qty} " + "WHERE material_id = #{materialId} AND warehouse_id = #{warehouseId} " + "AND quantity >= #{qty}") int deductStock(@Param("materialId") Long materialId, @Param("warehouseId") Integer warehouseId, @Param("qty") Integer qty); // 增加库存:利用唯一索引做 upsert @Insert("INSERT INTO t_stock (material_id, warehouse_id, quantity) " + "VALUES (#{materialId}, #{warehouseId}, #{qty}) " + "ON DUPLICATE KEY UPDATE quantity = quantity + #{qty}") int addStock(@Param("materialId") Long materialId, @Param("warehouseId") Integer warehouseId, @Param("qty") Integer qty); }deductStock的返回值是受影响行数。如果返回 0,说明库存不足或者物料仓库组合不存在,Service 层要抛业务异常并回滚事务。这个判断逻辑不能省,我见过有人直接update完不管返回值,结果库存扣成负数还在继续出库。
addStock用了ON DUPLICATE KEY UPDATE,依赖的就是前面建的那个唯一索引。如果索引没建,这条语句会变成每次插入新行,库存越加越多但查不出来。参数说明:materialId和warehouseId联合定位一条库存记录,qty是本次增减的数量,正数表示入库,负数表示出库。
Service 层的出库方法要包在一个事务里,先写单据明细,再扣库存,最后更新单据状态。三步顺序不能乱,否则中途失败会出现"单据已出库但库存没扣"的脏数据。
@Service public class OutboundService { @Autowired private OutboundOrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void confirmOutbound(Long orderId) { OutboundOrder order = orderMapper.selectById(orderId); if (order.getStatus() != 1) { throw new BizException("单据状态不允许出库"); } List<OutboundDetail> details = orderMapper.selectDetails(orderId); for (OutboundDetail d : details) { int rows = stockMapper.deductStock( d.getMaterialId(), order.getWarehouseId(), d.getQuantity()); if (rows == 0) { throw new BizException("物料 " + d.getMaterialId() + " 库存不足"); } } orderMapper.updateStatus(orderId, 2); } }@Transactional(rollbackFor = Exception.class)里的rollbackFor必须写,因为 Spring 默认只对RuntimeException回滚,如果抛的是受检异常,事务不会回滚,库存扣了但单据状态没改,数据就乱了。
4. 前端页面与接口联调:从登录到库存看板的完整链路
4.1 页面结构与接口约定
前端不用上 Vue 或 React 的重型方案,用 Thymeleaf 或者简单的 HTML + jQuery 就够。企业仓库系统的用户是仓管员和主管,他们关心的是操作快、字段清楚,不是页面炫酷。核心页面四个:登录页、物料管理页、入库单录入页、库存查询页。
接口统一用 RESTful 风格,返回格式固定为{code, msg, data}。code用 200 表示成功,500 表示业务异常,401 表示未登录。这个约定要在项目初期就定死,不然后面每个接口返回格式不一样,前端处理起来很痛苦。
// 库存查询页的 AJAX 调用示例 function loadStockList(page, size) { $.ajax({ url: '/api/stock/list', type: 'GET', data: { page: page, size: size }, success: function(res) { if (res.code === 200) { renderTable(res.data.records); renderPagination(res.data.total, page, size); } else { alert(res.msg); } }, error: function() { alert('网络异常,请稍后重试'); } }); }分页参数page从 1 开始还是从 0 开始,前后端要统一。我一般用 PageHelper 插件,pageNum从 1 开始,pageSize默认 10。如果前端传了page=0,PageHelper 会当成第一页处理,但最好在 Controller 里做参数校验,避免负数或超大值导致慢查询。
4.2 登录鉴权与权限控制
登录用 Session 方案就够了,不用上 JWT。企业内网系统,Session 的简单性比 JWT 的无状态更实用。用户表t_user里加一个role字段,0 是仓管员,1 是主管。仓管员只能录入单据,主管才能审核。这个权限判断在拦截器里做,不要在每个 Controller 里写 if-else。
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { HttpSession session = req.getSession(false); if (session == null || session.getAttribute("userId") == null) { resp.setStatus(401); resp.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}"); return false; } return true; } }拦截器注册时要排除登录接口和静态资源路径,否则登录页自己都被拦了,用户永远进不去。这个坑我在第一次搭 Spring Boot 项目时就踩过,浏览器一直转圈,看日志才发现是拦截器把/login也拦了。
5. 避坑与排查:仓库系统上线后最容易翻车的五个点
5.1 库存对不上:并发扣减没加锁
现象:两个仓管员同时给同一张出库单确认出库,库存扣了两次,但单据只记录了一次。原因:deductStock的 UPDATE 语句虽然带了quantity >= #{qty}条件,但如果两个事务同时读到相同的库存值,MySQL 的行锁在 RR 隔离级别下可能让第二个事务等待,等第一个提交后第二个再执行时条件已经不满足,返回 0 行。但如果代码里没判断返回值,就会继续往下走。解决:Service 层必须检查deductStock的返回值,为 0 时抛异常回滚。另外可以在t_stock上加version字段做乐观锁,但用条件 UPDATE 已经够用,不必过度设计。
5.2 单据号重复:序列生成没做并发控制
现象:同一秒内两个入库单生成了一样的order_no,插入时唯一索引报错。原因:用SELECT MAX(order_no) + 1的方式生成单号,并发下两个线程读到同一个最大值。解决:改用数据库自增序列或者 Redis 的INCR命令。如果不想引入 Redis,可以用 MySQL 的AUTO_INCREMENT配合日期前缀,或者用INSERT ... ON DUPLICATE KEY UPDATE重试。我一般会在单号生成方法上加synchronized,虽然性能差一点,但仓库系统的并发量完全扛得住。
5.3 中文乱码:字符集没统一
现象:物料名称在页面上显示正常,但存到数据库变成问号。原因:MySQL 建库时用了latin1,或者 JDBC URL 里没加characterEncoding=utf8。解决:建库语句必须指定DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci,JDBC URL 加useUnicode=true&characterEncoding=utf8,Tomcat 的server.xml里Connector加URIEncoding="UTF-8"。三个地方缺一不可,少一个就出乱码。
5.4 分页查询慢:深分页没优化
现象:库存列表翻到第 100 页时,接口响应超过 3 秒。原因:LIMIT 1000, 10这种深分页,MySQL 会扫描前 1000 行再丢弃。解决:用游标分页,传上一页最后一条记录的id,查询WHERE id > #{lastId} LIMIT 10。或者限制最大翻页数,企业仓库系统里没人会翻到第 100 页去找物料,一般用搜索功能直接定位。
5.5 事务失效:方法内部调用没走代理
现象:confirmOutbound方法加了@Transactional,但库存扣减失败后单据状态还是改了。原因:在同一个类里,一个方法直接调用另一个带@Transactional的方法,Spring 的 AOP 代理不生效。解决:把事务方法抽到独立的 Service 类里,或者用AopContext.currentProxy()获取代理对象再调用。最稳妥的做法是出库逻辑单独写一个OutboundTxService,由 Controller 直接注入调用。
6. 进阶技巧:用 SQL 视图做库存预警和报表导出
库存预警不需要在 Java 里写定时任务轮询,直接用 MySQL 视图加查询就能实现。建一个视图把库存低于安全库存的物料筛出来,前端页面每次加载时查这个视图,数据永远是实时的。
CREATE VIEW v_stock_warning AS SELECT m.material_code, m.material_name, m.safety_stock, s.quantity, w.warehouse_name FROM t_stock s JOIN t_material m ON s.material_id = m.id JOIN t_warehouse w ON s.warehouse_id = w.id WHERE s.quantity < m.safety_stock;这个视图的好处是逻辑集中在数据库层,Java 代码只需要SELECT * FROM v_stock_warning,不用关心关联条件。如果后面预警规则变了,比如改成低于安全库存的 80% 才预警,改视图定义就行,不用重新部署应用。
报表导出用 POI 写 Excel,但要注意数据量大时不要一次性查全量。我一般用 MyBatis 的Cursor做流式查询,每 1000 行写一次 Excel,避免 OOM。导出字段里库存数量要带上单位,不然财务对账时看到"100"不知道是 100 个还是 100 公斤。
@GetMapping("/export/stock") public void exportStock(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=stock.xlsx"); try (Cursor<StockVO> cursor = stockMapper.streamAll(); XSSFWorkbook workbook = new XSSFWorkbook()) { Sheet sheet = workbook.createSheet("库存"); int rowIdx = 0; for (StockVO vo : cursor) { Row row = sheet.createRow(rowIdx++); row.createCell(0).setCellValue(vo.getMaterialCode()); row.createCell(1).setCellValue(vo.getMaterialName()); row.createCell(2).setCellValue(vo.getQuantity() + vo.getUnit()); } workbook.write(response.getOutputStream()); } }Cursor是 MyBatis 的流式查询接口,底层用 JDBC 的fetchSize控制每次从数据库拉取的行数。MySQL 驱动默认会把所有结果集加载到内存,需要在 URL 里加useCursorFetch=true并设置defaultFetchSize=1000,否则Cursor不起作用。这个参数我在第一次做导出时没加,结果 5 万行数据直接把堆内存撑爆了,日志里一堆 GC 告警,排查了半天才发现是驱动配置的问题。
最后说一个习惯:每次改完库存相关的 SQL,我都会在测试环境用EXPLAIN看一眼执行计划,确认走了索引再提交。仓库系统的数据量可能不大,但库存表的更新频率高,一个全表扫描的 UPDATE 就能把行锁范围扩大,拖慢整个系统。这个习惯帮我省了很多次后悔药。希望帮到你。
本文还有配套的精品资源,点击获取