简介:这是一套面向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-Plus
2.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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus 启动器,注意别和原生 mybatis-spring-boot-starter 混用 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok,减少 getter/setter 噪音 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</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.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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 Page<Stock> pageQuery(int pageNo, int pageSize, String warehouseCode) { Page<Stock> page = new Page<>(pageNo, pageSize); LambdaQueryWrapper<Stock> 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实例。另外别在循环里查数据库,那是 N+1 问题的温床,用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。跑通这个,心里就有底了。做仓库系统,数据对得上比功能多更重要,这是我踩了无数坑之后最深的体会。希望帮到你。
本文还有配套的精品资源,点击获取