☰
Java+MySQL+Web企业仓库存储管理系统实战:从Excel台账到实时库存
2026/10/8 4:30:45 网站建设 项目流程

简介:这是一套面向高校计算机相关专业学生与Java Web初学者、课程设计开发者的企业仓库存储管理系统完整源码,采用Spring Boot+Java+Maven+MySQL+MyBatis技术栈,基于B/S结构,使用IntelliJ IDEA开发,可作为课程设计、毕业设计或Java Web综合练习的参考方案。系统功能覆盖客户、仓库、产品三类基本信息管理,以及用户权限管理、入库与出库记录管理、库存管理和系统日志查看,业务模块较为完整。资源包共221个文件,包含42个Java源文件、28个HTML页面、24个JavaScript脚本、9个XML配置、8个CSS样式及若干图片与字体资源,另有1个SQL建库脚本和1个properties配置文件,压缩包约6.95MB,目录结构清晰,便于按模块阅读与二次开发。目前已有568人学习下载,适合需要理解Spring Boot整合MyBatis、掌握增删改查与权限控制实现思路的读者参考借鉴。

1. 从一张 Excel 台账说起:企业仓库存储管理系统到底要解决什么

我见过太多中小企业的仓库管理现状:入库靠手写单据,出库靠微信群喊一声,月底盘点时三个人对着 Excel 台账核到凌晨两点,最后还是差了 17 件货对不上。老板问「这批物料到底还剩多少」,仓管翻半天表格说「大概还有两百多」。这不是段子,是我去现场做需求调研时真实遇到的场景。企业仓库存储管理系统要解决的,就是把这个「大概」变成「精确到个位、精确到库位、精确到批次」的实时数字。

这个标题里的关键词拆开看:Java 是后端语言选型,MySQL 是数据落地的关系型数据库,Web 是交付形态——仓管用浏览器打开就能操作,不需要装客户端。合在一起,就是一套典型的 JavaWeb 企业级应用。它适合谁?适合有两到三个仓库、SKU 在几千到几万级别、需要多角色协作(仓管、采购、财务、管理员)的中小企业,也适合正在找 JavaWeb 项目实战练手的开发者——因为这个业务场景的复杂度刚好卡在「不简单到没东西写,也不复杂到做不完」的区间。

我一般会把这类系统的核心诉求归成四件事:库存实时准确、出入库有据可查、多角色权限隔离、报表能导出给老板看。后面所有章节的选型和代码,都是围绕这四件事展开的。

2. 技术选型与数据库设计:为什么是 Spring Boot + MyBatis-Plus + MySQL

2.1 后端框架选型:别再用 Servlet + JSP 硬写了

现在做 JavaWeb 项目,如果还在用原生 Servlet + JSP,开发效率会低到让你怀疑人生。常见做法是 Spring Boot 打底,配合 MyBatis-Plus 做持久层。理由很直接:

  • Spring Boot 内嵌 Tomcat,打成 jar 包java -jar就能跑,部署时不用单独装 Web 服务器。
  • MyBatis-Plus 在 MyBatis 基础上封装了通用 CRUD,单表增删改查不用写 XML,BaseMapper直接给你selectById、insert、updateById。
  • 配合 Lombok 省掉 getter/setter,实体类清爽很多。

有热词提到「mybatisplus根据java实体类生成创建表的sql语句」,这个能力在项目初期建表时确实省事,但我要泼一盆冷水:自动生成的 DDL 只能当草稿,不能直接上生产。它不会帮你加合理的索引、不会考虑字段长度、不会设置外键约束。我一般用它生成初版,然后手工改索引和约束。

依赖的核心坐标(Spring Boot 3.x 版本):

<!-- pom.xml 关键依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

注意 MyBatis-Plus 在 Spring Boot 3 下要用mybatis-plus-spring-boot3-starter,用错了会启动报NoClassDefFoundError,这个坑我踩过。

2.2 数据库表设计:六张核心表撑起整个系统

仓库系统的表不用多,但每张都要设计到位。核心是这六张:

表名作用关键字段
sys_user用户与角色id, username, password, role, status
warehouse仓库信息id, name, address, manager_id
goods商品/物料档案id, code, name, spec, unit, category_id
inventory实时库存id, goods_id, warehouse_id, quantity, warn_line
stock_in入库单id, order_no, goods_id, quantity, operator, create_time
stock_out出库单id, order_no, goods_id, quantity, operator, create_time

inventory表是灵魂,它记录每个商品在每个仓库的实时数量。这里有个设计决策:库存数量是实时算还是存字段?我的选择是存字段,出入库时同步更新。原因是仓库系统查询频率远高于写入频率,每次查库存都去SUM出入库记录,数据量一大就慢得没法看。代价是要保证出入库和库存更新在同一个事务里。

CREATE TABLE `inventory` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `goods_id` BIGINT NOT NULL COMMENT '商品ID', `warehouse_id` BIGINT NOT NULL COMMENT '仓库ID', `quantity` INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', `warn_line` INT DEFAULT 10 COMMENT '预警线', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_goods_warehouse` (`goods_id`, `warehouse_id`), KEY `idx_warehouse` (`warehouse_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实时库存表';

uk_goods_warehouse这个唯一索引是关键——它保证同一个商品在同一个仓库只有一条库存记录,从数据库层面杜绝了重复插入导致的库存翻倍。idx_warehouse是为了按仓库维度查库存列表时走索引。

2.3 出入库的事务处理:一个不能省 @Transactional 的地方

入库操作要同时做两件事:写一条stock_in记录,更新inventory的数量。这两步必须原子。我见过有人分开写,结果入库单写成功了、库存更新失败了,账实不符查了半天。

@Service public class StockInService { @Autowired private StockInMapper stockInMapper; @Autowired private InventoryMapper inventoryMapper; /** * 入库操作:写单据 + 更新库存,同一事务 * @param goodsId 商品ID * @param warehouseId 仓库ID * @param quantity 入库数量,必须为正 */ @Transactional(rollbackFor = Exception.class) public void doStockIn(Long goodsId, Long warehouseId, Integer quantity) { if (quantity == null || quantity <= 0) { throw new BizException("入库数量必须大于0"); } // 1. 写入库单 StockIn record = new StockIn(); record.setOrderNo(generateOrderNo("IN")); record.setGoodsId(goodsId); record.setWarehouseId(warehouseId); record.setQuantity(quantity); record.setCreateTime(new Date()); stockInMapper.insert(record); // 2. 更新库存:存在则累加,不存在则插入 Inventory inv = inventoryMapper.selectOne( new LambdaQueryWrapper<Inventory>() .eq(Inventory::getGoodsId, goodsId) .eq(Inventory::getWarehouseId, warehouseId)); if (inv == null) { inv = new Inventory(); inv.setGoodsId(goodsId); inv.setWarehouseId(warehouseId); inv.setQuantity(quantity); inventoryMapper.insert(inv); } else { inv.setQuantity(inv.getQuantity() + quantity); inventoryMapper.updateById(inv); } } }

rollbackFor = Exception.class不能省。默认 Spring 只对RuntimeException回滚,如果抛的是受检异常,事务不会回滚,库存就脏了。generateOrderNo用「前缀 + 时间戳 + 随机数」生成,别用自增 ID 当单号,否则单号会泄露业务量。

3. 核心功能落地:从登录鉴权到库存预警的完整链路

3.1 登录鉴权:Session 还是 JWT

Web 项目做权限,两条路:Session 和 JWT。我的建议是后台管理系统用 Session,前后端分离用 JWT。这个项目如果是传统 Web 页面(Thymeleaf 或 JSP 渲染),Session 更简单,Spring Security 或拦截器都能搞定。如果是 Vue/React 前端,用 JWT,token 放请求头。

用拦截器做 Session 鉴权的核心代码:

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 String uri = request.getRequestURI(); if (uri.contains("/login") || uri.contains("/static/")) { return true; } HttpSession session = request.getSession(false); if (session == null || session.getAttribute("user") == null) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } return true; } }

getSession(false)表示不存在就不创建,避免每次请求都生成新 Session 浪费内存。返回 401 而不是重定向到登录页,是因为前端拿到 401 后自己跳转更灵活。

3.2 库存预警:定时任务还是查询时判断

库存低于预警线要提醒。两种做法:定时任务扫全表,或者查询库存列表时实时判断。我选后者,因为定时任务扫全表在库存表几万行时开销不小,而且预警本身是「看的时候才需要知道」的信息。

public List<InventoryVO> listInventory(Long warehouseId) { List<InventoryVO> list = inventoryMapper.selectInventoryList(warehouseId); for (InventoryVO vo : list) { // 库存低于预警线,标记为预警状态 vo.setWarning(vo.getQuantity() < vo.getWarnLine()); } return list; }

selectInventoryList在 XML 里做goods和warehouse的关联查询,一次查出商品名、仓库名、库存数,避免 N+1 查询。前端拿到warning=true的行标红显示。

3.3 分页查询:MyBatis-Plus 分页插件配置

列表页必须分页,否则几千条数据一次查出来页面直接卡死。MyBatis-Plus 的分页需要手动配置插件:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件,指定数据库类型为 MySQL interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

不配这个插件,Page对象传进去也不会真正分页,SQL 里不会拼LIMIT,这是新手最容易翻车的地方之一。分页参数一般pageNum从 1 开始,pageSize默认 10,前端传参时注意别传 0,否则LIMIT -1会报语法错误。

4. 避坑与排查:那些让我加班到深夜的坑

4.1 库存更新并发问题:两个仓管同时出库同一商品

现象:两个仓管同时给同一个商品出库,各出 5 件,库存本来 8 件,结果变成 -2 件。

原因:先select查库存,再update减数量,两个线程都查到 8,都减 5,最后写回 3 或者 -2,取决于执行顺序。这是典型的读-改-写竞态。

解决:用数据库行锁。在select时加FOR UPDATE,或者直接用原子 SQLUPDATE inventory SET quantity = quantity - #{num} WHERE goods_id = ? AND quantity >= #{num},靠quantity >= num条件保证不会超卖,返回影响行数为 0 就说明库存不足。

// 原子扣减,避免并发超卖 @Update("UPDATE inventory SET quantity = quantity - #{num} " + "WHERE goods_id = #{goodsId} AND warehouse_id = #{warehouseId} " + "AND quantity >= #{num}") int deductStock(@Param("goodsId") Long goodsId, @Param("warehouseId") Long warehouseId, @Param("num") Integer num);

4.2 MySQL 8 时区问题:时间差 8 小时

现象:入库单的create_time存进去是下午 3 点,查出来变成早上 7 点。

原因:MySQL 8 的 JDBC 驱动默认用 UTC 时区,而服务器在东八区,写入和读取时区不一致。

解决:连接串加serverTimezone=Asia/Shanghai,或者用useSSL=false&serverTimezone=GMT%2B8。完整连接串:

spring.datasource.url=jdbc:mysql://localhost:3306/warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true

allowPublicKeyRetrieval=true在 MySQL 8 用caching_sha2_password认证插件时是必须的,不加会报Public Key Retrieval is not allowed。

4.3 中文乱码:从数据库到页面全链路排查

现象:商品名「螺丝刀」存进去变成「??」。

原因:三个环节任一没设对都会乱码——数据库字符集、连接串字符集、页面编码。

解决:建库时CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,连接串加characterEncoding=utf8,页面<meta charset="UTF-8">。用utf8mb4而不是utf8,因为 MySQL 的utf8是阉割版,存不了 emoji 和部分生僻字。

4.4 事务失效:方法内部调用不生效

现象:doStockIn加了@Transactional,但入库单写进去了、库存没更新,事务没回滚。

原因:在同一个类里,A 方法直接调 B 方法(this.b()),B 方法的@Transactional不生效,因为没走代理对象。

解决:把事务方法抽到单独的 Service 类,或者注入自己@Autowired private StockInService self;用self.doStockIn()调用。最干净的做法是拆类。

4.5 分页总数不对:关联查询的 count 陷阱

现象:库存列表分页,每页 10 条,但总数显示 1000,实际只有 200 条。

原因:MyBatis-Plus 自动生成的 count SQL 在关联查询时可能把 join 后的行数当总数,如果 join 产生笛卡尔积就会偏大。

解决:自定义 count 查询,在 XML 里手写SELECT COUNT(DISTINCT i.id) FROM inventory i LEFT JOIN goods g ...,用DISTINCT去重。

5. 进阶技巧:把报表导出和权限控制做扎实

5.1 报表导出:EasyExcel 比 POI 省内存

库存报表要导出 Excel 给老板。用 Apache POI 写几万行会 OOM,因为它是全量加载到内存。用阿里开源的 EasyExcel,逐行写,内存占用极低。

public void exportInventory(HttpServletResponse response, Long warehouseId) throws IOException { List<InventoryExportVO> data = inventoryMapper.selectExportData(warehouseId); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); // 文件名用 URL 编码,避免中文乱码 String fileName = URLEncoder.encode("库存报表", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), InventoryExportVO.class) .sheet("库存") .doWrite(data); }

InventoryExportVO上用@ExcelProperty("商品名称")注解指定列名。文件名用filename*=utf-8''格式,比直接filename=对中文兼容性好。注意导出接口不要走拦截器的 JSON 响应逻辑,否则会把 Excel 流覆盖掉。

5.2 权限控制:用注解做细粒度鉴权

不同角色能看的菜单不一样。仓管只能出入库,财务只能看报表,管理员全权限。用自定义注解 + 拦截器实现:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); // 允许的角色 }

拦截器里读方法上的注解,比对当前用户角色。这样每个接口需要什么权限一目了然,比在 XML 里配 URL 拦截直观得多。注意注解要加在 Controller 方法上,加在 Service 上拦截器读不到。

5.3 一个验证系统是否可靠的小习惯

我做完这类系统,一定会做三件事验证:第一,用两个浏览器同时登录不同账号,对同一商品并发出库,看库存会不会变负;第二,把数据库连接断掉,看前端报错是不是友好的提示而不是一堆堆栈;第三,导出一次全量报表,看数据条数和数据库COUNT(*)是否一致。这三件事花不了半小时,但能挡住 80% 上线后才会暴露的问题。

做仓库系统最忌讳的就是「看起来能跑就行」。库存数字差一件,可能就是一整批订单发错。我现在的习惯是:任何涉及库存增减的代码,写完先自己用并发工具压一遍,确认没有超卖才提交。这个习惯帮我省掉了无数次半夜被叫起来查账的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询