
简介这是一套面向Java初学者与毕业设计学生的仓库管理系统完整项目源码围绕库存跟踪、商品管理与价格维护等企业日常运营场景展开帮助读者理解Java Web业务系统的实际构建流程。压缩包共104个文件约5.51MB以73个class编译文件与14个java源码为主另含3个jar依赖、1个sql建库脚本及少量jpg、gif、png界面截图并附带project、classpath等工程配置文件便于直接导入IDEA运行调试。项目覆盖Java SE基础、MVC设计模式、Spring依赖注入与事务管理、MyBatis持久层操作、Servlet与JSP交互以及用户认证授权、商品增删改查、价格与促销规则等核心模块数据库设计遵循第三范式。目前已有1067人学习下载适合作为毕业设计参考或Java后端入门练手项目帮助读者掌握从需求到落地的完整开发思路。1. 从一张入库单说起Java 仓库管理系统到底在管什么很多做 Java 的人第一次接触仓库管理系统都是被一张入库单逼出来的。货到了仓管在纸上划两笔财务那边对不上数采购说早就下了单销售说客户等着发货——信息全断在几个 Excel 里。仓库管理系统要解决的就是把这套「收货、上架、拣货、出库、盘点」的动作变成一套有状态、可追溯、能对账的数据流。它不神秘本质就是围绕库存这张表做增删改查再加上单据流转和权限控制。这个方向适合谁一是刚学完 Java 基础、想找一个能写进简历的完整项目练手的人二是中小团队里被临时派去搭内部工具的后端。它不需要高并发但特别考验你对业务状态、事务边界和数据一致性的理解。下面我按自己做过的一套 Spring Boot MyBatis-Plus 方案把选型、建表、核心接口和踩过的坑讲清楚你照着能跑起来一个最小可用版本。2. 技术选型与工程骨架为什么是 Spring Boot MyBatis-Plus2.1 选型理由别一上来就上微服务仓库管理系统的典型特征是单机部署、数据量中等、业务逻辑集中在单据和库存的联动上。这种场景下Spring Boot 单体应用是最划算的。它把 Tomcat、配置、依赖注入打包在一起java -jar就能起运维成本低。数据库层我一般选 MyBatis-Plus而不是 JPA原因很实际仓库系统里大量是「按条件查列表 分页 动态拼接」MyBatis-Plus 的QueryWrapper写起来直观而且它支持根据 Java 实体类反向生成建表 SQL省掉手写 DDL 的重复劳动。热搜里常出现「mybatisplus根据java实体类生成创建表的sql语句」这确实是它一个被低估的能力。你只要在实体类上标好TableName、TableId、TableField配合它的代码生成器或Db工具就能把表结构推导出来。对新手来说这比先啃一遍数据库设计规范再动手要友好得多。技术栈定下来JDK 17、Spring Boot 3.x、MyBatis-Plus 3.5.x、MySQL 8、Maven。别用 JDK 8 了新项目没必要背这个包袱。2.2 用 Maven 搭出可运行骨架先建工程。我习惯用start.spring.io生成或者直接手写pom.xml。核心依赖就这几个!-- pom.xml 关键依赖版本按你本地仓库实际可用的来 -- dependencies !-- Web 层提供 REST 接口和内嵌 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus 启动器注意别和原生 mybatis-spring-boot-starter 混用 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Lombok减少 getter/setter 噪音 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies依赖说明mybatis-plus-boot-starter已经内含 MyBatis不要再单独引mybatis-spring-boot-starter否则会出现 Mapper 扫描冲突启动时报Invalid bound statement。MySQL 驱动从 8.x 起包名是com.mysql.cj.jdbc.Driver配置里别写成老版本。配置文件application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true # 数据库下划线字段自动映射 Java 驼峰 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打印 SQL上线关掉 global-config: db-config: id-type: auto # 主键自增简单场景够用map-underscore-to-camel-case这个开关一定要开否则create_time映射不到createTime查出来全是 null新手最容易在这卡半天。log-impl只在开发期开生产环境打 SQL 会拖性能。2.3 实体类与自动建表以库存表为例实体类这样写Data TableName(wms_stock) public class Stock { TableId(type IdType.AUTO) private Long id; private Long productId; // 商品 ID private String warehouseCode; // 仓库编码 private Integer quantity; // 当前库存数量 private Integer lockedQty; // 锁定数量出库单占用但未发货 TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }TableField(fill ...)配合一个MetaObjectHandler实现类就能在插入和更新时自动填时间不用每次手动 set。lockedQty这个字段是仓库系统的关键设计下单不等于扣库存先锁定发货才真正扣减这样能避免超卖。想根据实体生成建表 SQL可以用 MyBatis-Plus 的Db工具类或者用官方的代码生成器配置DataSourceConfig后运行。生成的 SQL 大致是CREATE TABLE wms_stock (...)你拿到后手动补上索引和注释再执行。别指望它生成的生产级 DDL 能直接用索引和字符集还得自己调。3. 核心业务落地入库、出库与库存扣减怎么写3.1 库存扣减的三种写法与事务边界库存扣减是整个系统最容易翻车的地方。常见有三种写法第一种先查再改。select出当前数量Java 里减完再update。这种写法在并发下必然超卖因为查和改之间有窗口。第二种SQL 原子扣减update wms_stock set quantity quantity - #{num} where product_id #{id} and quantity #{num}靠数据库行锁保证原子性返回影响行数为 0 就说明库存不足。第三种悲观锁select ... for update锁住行再操作简单但并发差。我一般用第二种配合事务。核心代码Service public class StockService { Autowired private StockMapper stockMapper; /** * 扣减库存返回是否成功 * param productId 商品 ID * param num 扣减数量必须为正 */ Transactional(rollbackFor Exception.class) public boolean deduct(Long productId, Integer num) { if (num null || num 0) { throw new IllegalArgumentException(扣减数量必须为正); } // 原子扣减quantity num 作为条件防止扣成负数 int rows stockMapper.deduct(productId, num); if (rows 0) { // 影响行数为 0说明库存不足或商品不存在 throw new BizException(库存不足扣减失败); } return true; } }Mapper 里对应Update(update wms_stock set quantity quantity - #{num}, update_time now() where product_id #{productId} and quantity #{num}) int deduct(Param(productId) Long productId, Param(num) Integer num);逻辑说明把判断条件写进where让数据库在一次原子操作里完成「检查 扣减」这是防超卖最省事的做法。Transactional保证扣库存和写单据在同一个事务里任何一步失败整体回滚。参数上num必须校验为正否则quantity - (-5)会变成加库存这是个隐蔽的坑。3.2 入库单与出库单的状态流转单据不能只有一张表得有状态。入库单典型状态待收货、已收货、已上架、已完成。出库单待拣货、已拣货、已发货、已完成。状态流转要用枚举管理别用魔法数字。public enum InboundStatus { PENDING_RECEIVE(0, 待收货), RECEIVED(1, 已收货), SHELVED(2, 已上架), FINISHED(3, 已完成); private final int code; private final String desc; // 构造和 getter 省略 }状态变更接口要做合法性校验只能从当前状态走到下一个允许的状态不能跳步也不能回退除非有反审核流程。我见过有人直接update status #{status}前端传什么就存什么结果单据状态乱成一锅粥对账时根本查不清。正确做法是在 Service 里判断if (current ! expectedFrom) throw ...。3.3 分页查询与条件拼接列表页是仓库系统用得最多的功能。MyBatis-Plus 的分页需要先注册拦截器Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件指定数据库类型为 MySQL interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不注册这个拦截器Page查询不会真正分页会把全表查出来在内存里切数据一多就 OOM。查询写法public PageStock pageQuery(int pageNo, int pageSize, String warehouseCode) { PageStock page new Page(pageNo, pageSize); LambdaQueryWrapperStock wrapper new LambdaQueryWrapper(); // 仓库编码非空才拼条件避免 like %% 全表扫 wrapper.eq(StringUtils.hasText(warehouseCode), Stock::getWarehouseCode, warehouseCode); wrapper.orderByDesc(Stock::getUpdateTime); return stockMapper.selectPage(page, wrapper); }eq的第一个参数是条件布尔值为 false 时这个条件不拼进 SQL这是 MyBatis-Plus 很实用的一个设计。orderByDesc按更新时间倒序保证最近变动的排前面。分页参数pageNo从 1 开始别传 0否则算出来的 offset 是负数。4. 避坑与排查那些让我加班到凌晨的问题4.1 启动报 Invalid bound statement现象项目能启动一调 Mapper 就抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。原因通常是 Mapper 接口没被扫描到或者 XML 文件没放在resources/mapper下、mapper-locations没配。解决在启动类加MapperScan(com.xxx.mapper)并在 yml 里配mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml。如果用的是纯注解 SQL检查方法名和Update里的语句有没有拼错。4.2 库存扣成负数现象盘点时发现某个商品库存是负的。原因多半是扣减 SQL 没加quantity #{num}条件或者并发下用了「先查再改」。解决把条件写进where并给quantity字段加unsigned约束兜底数据库层面直接拒绝负数。另外检查是不是有定时任务和手动操作同时改同一行事务隔离级别用默认的REPEATABLE READ就够。4.3 时间字段全是 null现象插入数据后create_time是空的。原因MetaObjectHandler没生效或者实体字段没标TableField(fill ...)。解决确认处理器类加了Component并被 Spring 扫描到insertFill和updateFill方法里对LocalDateTime类型做strictInsertFill。还有一种情况是数据库字段类型是datetime但 Java 用LocalDateTime驱动版本太老映射不上升级 MySQL 驱动即可。4.4 分页查全表导致内存溢出现象日志里出现java.lang.OutOfMemoryError: Java heap space堆内存调到 8000 还是报。原因分页拦截器没注册或者Page对象没传给selectPage。解决检查MybatisPlusConfig是否被加载selectPage的第一个参数必须是Page实例。另外别在循环里查数据库那是 N1 问题的温床用in批量查。4.5 事务不生效导致数据不一致现象扣库存成功但单据没写进去或者反过来。原因Transactional方法被同类内部调用代理没生效或者异常被 catch 了没重新抛出。解决把事务方法抽到独立的 Service通过注入调用catch 块里要么throw要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。rollbackFor Exception.class要显式写默认只回滚运行时异常。5. 进阶技巧用乐观锁和审计字段把系统做扎实基础版跑通后有两个地方值得再花点时间。第一个是并发更新同一条库存记录时的覆盖问题。原子扣减解决了「减」的并发但如果是「盘点调整」这种直接 set 数量的操作两个管理员同时改就会互相覆盖。这时候加乐观锁实体里加Version private Integer version;配置OptimisticLockerInnerInterceptor更新时 MyBatis-Plus 会自动带上version条件更新失败返回 0 行提示用户刷新重试。// 注册乐观锁插件注意顺序分页在前乐观锁在后 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());第二个是审计字段。除了create_time、update_time再加create_by、update_by配合MetaObjectHandler从当前登录用户上下文里取。仓库系统出了账实不符第一件事就是查谁在什么时候改的没有审计字段你只能干瞪眼。用户上下文可以用ThreadLocal存请求进来时由拦截器塞进去MetaObjectHandler里取出来填。Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { // 从 ThreadLocal 取当前用户取不到就填 system this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, createBy, String.class, CurrentUserHolder.get()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateBy, String.class, CurrentUserHolder.get()); } }strictInsertFill只在字段为 null 时填充不会覆盖你手动设的值这个语义要记牢。CurrentUserHolder用ThreadLocal实现记得在请求结束时remove()否则线程池复用时会串用户这是个血泪教训。验证这套东西有没有做对我的习惯是写一个集成测试并发 100 个线程扣同一商品库存初始 50最后看成功次数是不是正好 50库存是不是 0。跑通这个心里就有底了。做仓库系统数据对得上比功能多更重要这是我踩了无数坑之后最深的体会。希望帮到你。本文还有配套的精品资源点击获取