简介:面向课程设计与毕业设计的Spring Boot智能无人仓库管理系统,是一套可直接运行的完整工程项目。工程围绕入库、出库、库存管理、自动化调度等业务展开,后端由Java服务构成,前端搭配Vue页面,并包含SQL数据库脚本,适合需要实践Spring Boot全栈开发的学生参考。压缩包共373个文件、26.86MB,其中85个java文件对应控制器、服务与实体层代码,46个vue文件构建管理界面,161个svg提供图标资源,另附论文.doc、db.sql与运行说明,覆盖环境配置到启动验证的完整链路。已有155人学习下载。通过研读论文与源码,可掌握项目分层设计、数据库表结构搭建及常见异常处理思路,也能直接二次开发,用于课程设计或毕业设计的答辩准备。
1. 用 Spring Boot 做智能无人仓库管理,先把库存账做平
拿到一个“课设毕设 springboot 基于 Spring Boot 智能无人仓库管理-LW+源码可运行.zip”这样的压缩包,多数人的第一反应是赶紧跑起来看界面。但真正让这套系统在答辩或交付时撑住场的,不是前端页面有多炫,而是无人仓最核心的那本库存账能不能在并发出入库时依然平。无人仓和传统仓库的软件差异也就一句话:没有人去数货,系统必须自己能证明“账实一致”。这篇文章按我平时接手这类项目的顺序,把 Spring Boot 智能无人仓库管理从业务拆解、并发扣减、设备联动,到源码跑通和对账验收,完整过一遍。新手可以照着做,熟手可以重点看中间几章的并发方案选型和最后的流水对账手段。
2. 智能无人仓库的业务拆解:Spring Boot 四层架构和目录规范先定下来
2.1 把仓库业务拆成五个域:货品、库存、库位、任务、设备
智能无人仓库的业务并不复杂,复杂的是各模块之间的数据一致性。常见做法是先按领域拆出五个核心对象:货品、库存、库位、任务、设备。货品只管 SKU 主数据和规格,不存数量;库存表存“哪个 SKU 在哪个库位有多少件”,这是账本本体;库位表描述货架和巷道;任务表记录从“收到入库指令”到“AGV 完成搬运”的整个流程;设备表管理 AGV、传送带、扫码枪的状态。
五个域里最容易做错的是把“货品”和“库存”混在一张表里。一位有经验的后端会告诉你,这两个对象变化频率完全不同:货品一年改不了几次,库存每一分钟都在变。混在一起会让每次库存更新都产生多余的行锁竞争,而且没法单独做流水审计。对无人仓来说,流水审计恰恰是底线,因为现场没有人工复核环节,所有纠错都要靠流水倒推。
2.2 Spring Boot 四层架构怎么写,Controller 别直接碰 Repository
拿到源码后先看包结构,一般合格的课设毕设项目都是 Spring Boot 四层架构:Controller 层收请求、Service 层写业务、Repository 层做数据访问、Entity 层映射表结构。有些项目会额外加 DTO 包做参数传递,这属于加分项,不强求。四层最忌讳的是图省事在 Controller 里直接注入 Repository,刚开始写确实快,但事务边界就失控了,后面加并发控制时只能到处打补丁。
| 层次 | 包名示例 | 核心职责 | 典型陷阱 |
|---|---|---|---|
| Entity | entity | 与数据表一对一映射,字段类型和表结构对齐 | 在实体里写查询逻辑 |
| Repository | repository | 继承 JpaRepository,定义查询方法 | 在接口命名里写复杂SQL |
| Service | service | 事务边界、业务校验、跨仓库协作 | 事务注解加在私有方法上 |
| Controller | controller | 参数校验、协议转换、返回统一结构 | 直接返回实体对象泄露字段 |
Spring Boot 目录规范里,四层架构的包名要体现业务域,比如entity下面再分stock、device、task三个子包,而不是建一个大而全的 entity 包。这样做到后期,任务调度查库存、设备上报改任务状态,互相引用时路径清晰,不容易循环依赖。
2.2.1 用 Spring Data JPA 定义库存和任务的 Repository 接口
Repository 是 Spring Boot 里最省代码的一层。只要接口继承JpaRepository<实体, 主键类型>,框架自动补齐增删改查和分页能力,方法名按规范写就能自动生成 SQL。下面是一个简化版的库存实体和仓储接口,也是我经常给做无人仓项目的人推荐的起点写法。
@Entity @Table(name = "stock_inventory") public class Inventory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /** SKU 编码,对应货品表的业务主键 */ private String skuCode; /** 库位编码,例如 A-01-03 表示 A 区 01 巷道 03 货位 */ private String locationCode; /** 当前库存数量,无人仓里必须是非负数 */ private Integer quantity; /** 行记录版本号,乐观锁要做并发控制时用 @Version 标注 */ @Version private Long version; protected Inventory() { } public Inventory(String skuCode, String locationCode, Integer quantity) { this.skuCode = skuCode; this.locationCode = locationCode; this.quantity = quantity; } public void addQuantity(Integer qty) { this.quantity += qty; } }对应的 Repository 接口:
public interface InventoryRepository extends JpaRepository<Inventory, Long> { /** 按 SKU 和库位查库存,无人仓中一个库位一个 SKU 的记录是唯一约束 */ Optional<Inventory> findBySkuCodeAndLocationCode(String skuCode, String locationCode); /** * 用一条 UPDATE 语句完成“扣减且不允许扣成负数”。 * 返回 0 表示库存不足或记录不存在,业务层直接抛异常即可。 */ @Modifying @Query("update Inventory i set i.quantity = i.quantity - :qty " + "where i.id = :id and i.quantity >= :qty") int deductQuantity(@Param("id") Long id, @Param("qty") Integer qty); }这里有两个容易忽略的细节。第一,@Version是给后续乐观锁用的,版本号字段和@Version必须同时存在,否则框架拦截不到冲突。第二,deductQuantity这种原子 UPDATE 不走 JPA 的实体生命周期,所以必须加@Modifying,否则启动时直接报错。方法名findBySkuCodeAndLocationCode的字段顺序必须和实体属性按驼峰对应,写错一个字母,Spring Boot 启动就会失败并提示No property found。
3. 出入库与并发扣减:Spring Data JPA 的悲观锁、乐观锁和原子 UPDATE
3.1 事务边界放 Service 层,入库回滚才可控
先看一个最常见的错误:把@Transactional加在 Controller 的某个方法上,或者加在 Service 的私有方法上。两种情况事务都不会真正生效,因为 Spring 的声明式事务基于 AOP 代理,只有通过代理对象调用的公有方法才被拦截。正确做法是定义一个入库服务,把“查库存、改库存、写流水”三步包在同一个事务里,任何一个环节失败全部回滚。
@Service public class StockInService { private final InventoryRepository inventoryRepository; private final StockFlowRepository stockFlowRepository; public StockInService(InventoryRepository inventoryRepository, StockFlowRepository stockFlowRepository) { this.inventoryRepository = inventoryRepository; this.stockFlowRepository = stockFlowRepository; } /** 入库:库存不存在则新建,存在则累加,同时写一条流水 */ @Transactional public Long receive(StockInCommand command) { Inventory inventory = inventoryRepository .findBySkuCodeAndLocationCode(command.getSkuCode(), command.getLocationCode()) .orElseGet(() -> new Inventory(command.getSkuCode(), command.getLocationCode(), 0)); inventory.addQuantity(command.getQty()); inventoryRepository.save(inventory); StockFlow flow = new StockFlow(command.getSkuCode(), command.getLocationCode(), command.getQty(), StockFlowType.IN, LocalDateTime.now()); stockFlowRepository.save(flow); return flow.getId(); } }这段逻辑里,orElseGet负责处理首次入库的场景,省掉一处“先查是否存在再决定 insert 还是 update”的重复代码。StockFlow流水表和Inventory库存表在同一事务内写入,保证哪怕程序在写完库存后、写流水前崩了,数据库回滚后两边仍然一致。这是无人仓系统账实一致的第一道防线。
3.2 先查再改会遇到超卖,三种并发方案对比
如果入库只有一个业务员操作,先查再改没毛病。但无人仓的场景里,AGV 小车、提升机、人工复核 PC 可能同时对同一个库位发起出入库,这时“查出来 quantity=10,扣掉 3,再写回 7”就会出问题:两个请求都读到 10,都写回 7,库存凭空多出 3 件。解决思路分三种,按可靠性从低到高排。
| 方案 | Spring Boot 写法 | 适用场景 | 注意点 |
|---|---|---|---|
| 悲观锁 | @Lock(PESSIMISTIC_WRITE) | 极端竞争、冲突频发的热点库位 | 锁持有时间必须短,否则吞吐量暴跌 |
| 乐观锁 | @Version字段 + 重试 | 读多写少、冲突不频繁 | 冲突后靠业务层捕获异常重试 |
| 原子 UPDATE | @Modifying+JPQL | 只做纯数值加减的场景 | 拿不到更新前快照,需另查流水 |
先查再改 + 乐观锁是我个人最常用的组合。它不需要在整个事务期间锁住数据库行,commit 时版本号不一致会让事务失败并抛出ObjectOptimisticLockingFailureException,业务层捕获后重新执行即可。缺点是冲突严重时重试次数多,适合无人仓这种“冲突不常见但必须防”的场景。
悲观锁则适合那种“这个库位今天一定会被多个 AGV 同时争抢”的热点数据。在 Repository 方法上加@Lock(PESSIMISTIC_WRITE)再配合@Transactional,Spring Data JPA 会自动在生成的 SQL 后面加上FOR UPDATE,其他事务只能等锁释放。代价是并发能力下降,如果整条巷道只有一个热门口,悲观锁会让 AGV 排队时间明显变长。
原子 UPDATE 是数据库层面的最终兜底,代码写起来最简单,但使用限制也最明显:它只能做“减库存”或“加库存”这种无状态操作,如果业务还要同时判断库存快照、记录变更前数量,就必须再查一次数据库。
3.3 用边界值测试验证并发方案是否生效
选好方案后,建议写一个最简单的边界测试:把库存初始化为 1,同时发 10 个扣减 1 的请求,最后库存必须是 0,且只有 1 个请求成功。用 Spring Boot 的测试切片就能跑,不一定要起完整环境。
@SpringBootTest class DeductConcurrencyTest { @Autowired private InventoryRepository inventoryRepository; @Test void testDeductWithConcurrency() throws Exception { // 初始化库存为 1 Inventory inv = new Inventory("SKU-TEST-001", "A-01-01", 1); inventoryRepository.save(inv); int threadCount = 10; CountDownLatch startGate = new CountDownLatch(1); CountDownLatch doneGate = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(); for (int i = 0; i < threadCount; i++) { new Thread(() -> { try { startGate.await(); int affected = inventoryRepository .deductQuantity(inv.getId(), 1); if (affected == 1) { successCount.incrementAndGet(); } } catch (Exception ignored) { // 乐观锁冲突也属于扣减失败,不计数 } finally { doneGate.countDown(); } }).start(); } startGate.countDown(); doneGate.await(); Inventory after = inventoryRepository.findById(inv.getId()).orElseThrow(); // 成功数必须是 1,剩余库存必须是 0 System.out.println("success=" + successCount.get()); System.out.println("remain=" + after.getQuantity()); } }这个测试看着简单,但能暴露出三类问题:deductQuantity没加@Modifying导致运行期异常、quantity >= :qty条件写反导致永远扣不动、以及事务注解缺失导致原子 UPDATE 不在事务内执行。基本逻辑没验证清楚就去调 AGV 设备对接,排查成本会高得多。
4. 无人化闭环怎么补:任务状态机、WebSocket 看板与设备图片上传
4.1 任务状态机是无人仓的核心闭环
无人仓的“无人”体现在哪里?体现在 AGV 搬运任务从生成、执行、完成到异常补偿,全程不需要人工干预。用状态机管理任务的生命周期比在 Service 里塞一串 if/else 清晰得多。常用做法是定义一个枚举状态,从PENDING待执行到EXECUTING执行中,再到SUCCESS成功或FAILED失败,失败重试时回到RETRY。
public enum TaskState { PENDING, EXECUTING, SUCCESS, FAILED, RETRY }状态流转不能谁想改就改。只要是往EXECUTING方向走,只有“收到设备心跳且任务被指派”才能推进;往SUCCESS走,必须“设备确认完成且目标库位库存已更新”。如果状态跳过了某一步,流水账对不上时根本无从排查。所以任务表设计时要有current_state、last_error、retry_count、assigned_device四个字段,后面两个在无人仓排障时价值极高。
4.2 设备心跳与离线检查
设备表和任务表是一对多关系,一台 AGV 同一时刻只能执行一个任务,任务表用assigned_device字段做关联。无人仓里设备会定时上报心跳,比如每 5 秒一次。Spring Boot 里用@Scheduled做离线扫描,超过阈值没上报就标记离线,同时把该设备上未完成的任务重新投入待执行队列。
@Component public class DeviceHeartbeatScanner { private final DeviceRepository deviceRepository; private final TaskRepository taskRepository; public DeviceHeartbeatScanner(DeviceRepository deviceRepository, TaskRepository taskRepository) { this.deviceRepository = deviceRepository; this.taskRepository = taskRepository; } /** fixedRate 表示按固定速率触发,不管上一次是否执行完毕 */ @Scheduled(fixedRate = 5000) public void scanHeartbeatTimeout() { LocalDateTime threshold = LocalDateTime.now().minusSeconds(15); List<Device> staleDevices = deviceRepository.findByLastHeartbeatBefore(threshold); for (Device device : staleDevices) { device.markOffline(); deviceRepository.save(device); // 该设备上执行中的任务回退到 RETRY,等待其他设备接走 taskRepository.retryByDeviceId(device.getId()); } } }@Scheduled(fixedRate = 5000)每 5 秒跑一次,findByLastHeartbeatBefore查所有心跳时间早于 15 秒前的设备,这是无人仓离线判断最常见的手段。心跳阈值不能设太短,AGV 的通信偶尔延迟一下就会误判离线;也不能设太长,否则设备真停了,任务卡 1 分钟才发现。设备多、任务量大时,fixedRate的扫描要防重入,记得加一个分布式锁或进程内锁,避免上一轮没跑完下一轮又进来。
4.3 看板推送用 STOMP 比原生 WebSocket 更省事
无人仓总控界面要实时刷新任务进度和库存变动,HTTP 轮询能实现但不优雅,WebSocket 更合适。Spring Boot 里最省事的方案是用 Spring 自带的 STOMP 支持,它把原生 WebSocket 的消息路由、订阅、广播都封装好了。一般会在WebSocketConfigurer里注册一个/ws端点,前端通过 SockJS 连接,服务端用SimpMessagingTemplate向指定频道广播任务状态。
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 前端连接地址:/ws?token=xxx registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 服务端推送地址前缀 registry.enableSimpleBroker("/topic", "/queue"); // 客户端发到服务端的地址前缀 registry.setApplicationDestinationPrefixes("/app"); } }推送时调用SimpMessagingTemplate.convertAndSend("/topic/task", payload),所有订阅该频道的看板页面都会即时收到。/topic适合广播,比如“任务状态变化所有人可见”;/queue适合点对点,比如“某台 AGV 的设备告警只推给负责该区域的账号”。STOMP 的另一个好处是前端可以用成熟客户端@stomp/stompjs,不用自己处理二进制帧,适合无人仓总控这种多页面实时刷新场景。
4.4 设备图片上传:文件名必须防重
设备管理和货品管理里经常要传设备照片、货品图片,这就用到 Spring Boot 上传文件。上传本身不难,麻烦的是文件重名覆盖和路径穿越。常见做法是用 UUID 重新生成文件名,扩展名保留原来的,存储目录固定,文件名不信任用户输入。
@PostMapping("/api/devices/image") public String uploadDeviceImage(@RequestParam("file") MultipartFile file) throws IOException { // 原始文件名只用来取扩展名,不直接落盘 String originalFilename = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(originalFilename); String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext; Path uploadDir = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize(); Path targetPath = uploadDir.resolve(storedName).normalize(); Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); return storedName; }StringUtils.getFilenameExtension负责提取.jpg、.png这类扩展名,可以过滤掉路径分隔符。normalize()是防止路径中出现../向上跳转,确保最终保存路径一定还在上传目录内。REPLACE_EXISTING配合 UUID 文件名时基本不会触发覆盖,但保留这个选项可以让同一次上传失败重传时不残留半截文件。
上传相关的参数集中在application.yml里,spring.servlet.multipart.max-file-size控制单文件大小,max-request-size控制一次请求的总大小。设备照片一般 5MB 够用,但无人仓拍照存档可能会传高清图,要按现场情况放开到 10MB 或 20MB。超过限制后 Spring Boot 会抛MaxUploadSizeExceededException,记得加一个@RestControllerAdvice统一处理,否则前端看到的是晦涩的 500。
5. 跑通 LW 源码:数据库初始化、application.yml 参数与 Actuator 验证
5.1 拿到源码先看哪个目录
解压 zip 后先不要急着mvn spring-boot:run,先看三个位置:pom.xml、src/main/resources/application.yml、src/main/resources/db/。pom.xml里 Spring Boot 版本和 JDK 版本必须匹配,比如 Spring Boot 2.7 配 Java 8 或 11,Spring Boot 3.x 必须 Java 17 以上,版本对不上启动时会直接报UnsupportedClassVersionError。db目录下一般放着schema.sql或init.sql,这是数据库表结构的唯一权威来源,比对着实体类猜字段快得多。
LW 文档通常和源码配套,里面的数据库 ER 图直接对应schema.sql。先对照 LW 里的表说明把库建好,再启动 Spring Boot 项目,能避开大量因表和实体不匹配导致的启动报错。
5.2 数据库 SQL 和初始化步骤
无人仓项目一般用 MySQL,因为带事务和行锁,SQLite 做课设省事但不适合演示并发业务。初始化步骤如下:
# 登录 MySQL,创建数据库 mysql -u root -p # 在 MySQL 提示符下执行 CREATE DATABASE warehouse CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE warehouse; SOURCE /path/to/schema.sql;utf8mb4比utf8多支持表情符号,无人仓看板里如果显示设备状态图标,表情图标存不进 utf8,这里直接用 utf8mb4 一步到位。建表顺序有讲究,stock_inventory引用product和location,storage_task引用device,如果schema.sql里提前写了所有建表语句且顺序正确,SOURCE一次就能全跑完。
5.3 启动前的核心参数配置
application.yml里最重要的就是数据源配置,这里直接把一套常用的可运行配置贴出来,参数含义写在注释里。
spring: datasource: url: jdbc:mysql://localhost:3306/warehouse?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: # 尽量用 none,让 schema.sql 管表结构,update 只适合开发期 ddl-auto: none show-sql: true properties: hibernate: format_sql: true servlet: multipart: max-file-size: 5MB max-request-size: 10MB server: port: 8080 management: endpoints: web: exposure: include: health,info,metricsserverTimezone=Asia/Shanghai解决 MySQL 驱动和本地时区不一致导致的日期报错;allowPublicKeyRetrieval=true解决 MySQL 8 的 caching_sha2_password 认证插件在非 SSL 连接下的报错。ddl-auto我建议设成none,表结构完全由schema.sql控制,因为update只会加表加字段,不会删字段,如果源码实体里删过字段,数据库里会残留旧列,业务查询反而出错。show-sql: true开发期开着能看到每次操作的 SQL,方便对账时确认是否走了预期索引。
5.4 跑不起来的排查表
| 启动报错 | 原因 | 处理方式 |
|---|---|---|
Communications link failure | MySQL 没启动或 URL 写错 | 确认 3306 端口可用,URL 数据库名和实际一致 |
Access denied for user | 用户名密码错误 | 检查username/password,MySQL 8 还要看认证插件 |
Port 8080 was already in use | 端口被占用 | java -jar xxx.jar --server.port=8081换端口 |
Table 'xxx' doesn't exist | schema.sql没执行或执行错库 | 重新SOURCE schema.sql,show tables验证 |
Failed to configure a DataSource | 数据源配置缺失 | 确认spring.datasource四项一个不少 |
--server.port=8081这种命令行参数只对本次启动生效,适合临时改端口调试,不用去改 yml。
5.5 启动后用 Actuator 验证
Spring Boot Actuator 是源码运行是否健康的最直观验证方式。启动无报错不代表没问题,打开浏览器访问http://localhost:8080/actuator/health,返回{"status":"UP"}说明应用状态健康。/actuator/health还会联动检查数据库连接,数据库挂了会返回DOWN。
# 查看整体健康状态 curl http://localhost:8080/actuator/health # 查看关键指标,内存和线程池情况 curl http://localhost:8080/actuator/metricsmanagement.endpoints.web.exposure.include只暴露了health,info,metrics三个端点,够用且不过度。注意 Actuator 端点如果全部暴露到公网,/actuator/env会泄露数据源密码、/actuator/heapdump能直接下载堆内存快照,这是 Spring Boot 应用常见的未授权访问风险。个人开发机和答辩演示环境无所谓,但只要部署到服务器,尽量用 Spring Security 保护 actuaotr 端点。
6. 给这套智能无人仓库做验收:流水对账与并发压测
6.1 库存流水对账
无人仓系统交付前,我会先做一次“流水对账”:把库存表的当前数量,和所有出入库流水累加后的数量做对比,不一致就说明事务边界或状态机有 bug。对账 SQL 可以直接在 MySQL 里执行。
select i.sku_code, i.location_code, i.quantity as stock_qty, sum(case when f.flow_type = 'IN' then f.qty else -f.qty end) as flow_qty from stock_inventory i left join stock_flow f on i.sku_code = f.sku_code and i.location_code = f.location_code group by i.sku_code, i.location_code, i.quantity having stock_qty <> flow_qty;这条 SQL 把每个库存记录的当前数和流水表按sku_code + location_code聚合后的净变化做差,只要having查出任何一行,就说明有多扣、少扣、或者流水缺失。对账发现差异后,优先查storage_task表里该库位关联任务的状态,看是否有任务显示了SUCCESS但库存更新漏掉。
6.2 并发扣减压测
对账通过后再做并发验证。最简单的方式是用ab命令模拟多并发出库请求,观察是否出现库存负数。
# -n 总请求数,-c 并发数,-p 请求体文件,-T 指定 Content-Type ab -n 2000 -c 100 -p stock_out.json -T application/json http://localhost:8080/api/stock/outstock_out.json里放一个固定的 SKU 和库位,扣减数量设为 1。跑完后去数据库查该库存记录,只要不出现负数,且库存减少数量等于成功响应数量,就说明并发扣减没毛病。如果是分布式部署,ab单机并发加不上去,可以用 Jmeter 分布式压测,但数据库行锁会成为瓶颈。
6.3 乐观锁冲突重试的落地姿势
最后是一个能立即用上的技巧。如果项目用的是@Version乐观锁,扣减失败抛ObjectOptimisticLockingFailureException时不能直接让用户重试,业务层要做一次受限重试。
public boolean deductWithRetry(Long inventoryId, Integer qty, int retryTimes) { for (int i = 0; i < retryTimes; i++) { try { stockOutService.deduct(inventoryId, qty); return true; } catch (ObjectOptimisticLockingFailureException e) { // 冲突说明有别的任务刚改过库存,刷新后继续尝试 try { Thread.sleep(50L * (i + 1)); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return false; } } } return false; }重试次数建议控制在 3 次以内,每次间隔按50ms * 次数递增,避免冲突后立刻重试又撞上同一波写请求。超过重试上限仍失败,就返回明确提示而不是把异常堆栈抛给前端。这套手法用上后,再把 6.1 的对账 SQL 跑一遍,账平了,这套 Spring Boot 智能无人仓库管理才能真正算“可运行”。
本文还有配套的精品资源,点击获取