这个项目我前后做了两个版本:第一版只在技术上跑通了出入库,第二版才真正把“无人化”的逻辑补全。下面我拆开讲这套基于 Spring Boot 2 + Vue3 + MyBatis-Plus + MySQL 8.0 的智能无人仓库管理系统做得比较合理的地方,也包括一些你一定会在开发中踩到的坑。
1. 项目定位与整体设计思路
1.1 无人仓库系统到底解决什么问题
很多中小仓库现状是这样的:货摆在货架上,靠仓管员的脑子记位置,入一单记一单,月底盘库存全靠人工数。一旦商品种类超过几百个,这种模式就彻底失控——找不到货、库存对不上、过期品压仓库。所谓“无人仓库”,不是真的没有人的参与,而是把货位分配、库存记录、出入库指令、预警建议这些过去由人做决策的环节,变成系统自动计算和驱动。
所以这个项目里最核心的不是列表查询,而是几个业务闭环:入库时系统自动推荐库位,扫码确认后库存实时更新;出库时按“先到期先出”自动锁定货架上的批次;库存低于阈值时看板推送预警。这些逻辑从后端接口到数据库表都要提前规划好。
1.2 为什么是这套技术栈
整套技术栈是当前前后端分离项目里非常成熟的一组搭配:
- Spring Boot 2:生态成熟,资料多,很多生产环境还在用它,对 Java 8/11 支持好。如果一上来就选 Spring Boot 3,会踩到 Jakarta EE 命名空间迁移、Spring Security 6 配置方式变化这些额外麻烦,学习和教学场景没必要。
- Vue3 + Vite:Vue3 的 Composition API 更适合写复杂度较高的后台管理界面,代码组织比 Options API 清晰,Vite 的本地启动速度和热更新体验明显优于 Webpack。
- MyBatis-Plus:在 MyBatis 之上提供了 BaseMapper、通用分页、条件构造器、逻辑删除等能力,写增删改查的效率高很多,又不至于像 JPA 那样让人对 SQL 失去掌控感。
- MySQL 8.0:窗口函数、公共表表达式(CTE)、更好的 JSON 支持,在库存统计、报表查询里非常实用。
project 文档里包含数据库初始化 SQL、接口文档、部署说明。这意味着拿到源码后,不需要靠猜就能把整套环境拉起来,这也是这套源码能作为毕业设计或课程设计直接用的关键原因。
2. 项目模块拆解:从登录到大屏看板
2.1 权限模型:JWT + RBAC 的组合方式
仓库管理系统一定有操作员、仓管员、管理员这几类角色,权限落地用的是经典的 RBAC 模型:
用户表 -> 用户角色关联表 -> 角色表 -> 角色菜单关联表 -> 菜单表后端认证我推荐用 JWT,而不是传统的 Session。原因很简单:前后端分离后,后端接口可能同时被管理后台和扫码终端调用,JWT 天然适合这种无状态接口鉴权。登录成功后返回 token,前端把它放在 axios 请求头的 Authorization 字段里,后端通过拦截器校验。
一个很容易踩的坑是 JWT 密钥和过期时间的设计。实际项目中我把密钥放到了application-dev.yml和application-prod.yml里分开维护,过期时间设置为 24 小时。太小会让操作员频繁掉线,太大又有安全风险。真要做严格一点,可以把不活跃用户的下线逻辑做成 Redis 维护的黑名单,但第一版不建议搞这么重。
2.2 库存核心:库位、库存、出入库单怎么设计
仓库管理系统的数据模型是标准的“库位 + 批次库存 + 单据流水”结构:
- 库位表wms_location:记录仓库里的物理货位,包含库区编码、库位编码、温度属性(常温/冷藏)、占用状态。
- 库存表wms_stock:按“SKU + 库位 + 批次号”维度存库存数量,这里一定要加批次号,否则后面做效期管理就是空的。
- 入库单wms_inbound_order和入库明细表:头部记录供应商、入库时间、状态;明细记录 SKU、数量、生产日期、到期日期。
- 出库单wms_outbound_order:记录领用部门或客户、出库时间、状态,明细关联到具体的库位和批次。
数据库表之间不要直接用外键物理关联,我吃过这个亏。用逻辑外键(也就是普通索引)就足够了,不然删除和初始化数据时会非常痛苦,MyBatis-Plus 的分页查询也会因为外键约束折腾出各种啼笑皆非的问题。
2.3 无人化的关键:智能库位分配与库存预警
这部分是整个项目真正的亮点,也是最容易被做成普通增删改查的地方。智能库位的核心规则可以很朴素:商品入库时根据商品分类自动匹配适合的库区,再在当前库区里找空闲库位。
我把库位分配策略简化成三步:
- 根据商品编码前缀匹配库区(比如食品类走常温区、生鲜类走冷藏区),这一步能避免把需要冷藏的商品放进普通货架。
- 优先放入已经存放了同 SKU 的库位,让同商品尽量聚集,减少后续拣货路径。
- 如果同 SKU 没有在库,则查找该库区内占用率最低的空库位。
这段逻辑写成 Java 其实就是一个带优先级的查询服务,但业务价值比其他页面大得多。出库时同样需要“智能”,系统按“先到期先出”的规则锁定库存明细表中最早到期的那一批。这个策略在上线运行后效果特别明显,临期商品的报废比例会大幅下降。
预警模块建议用 Spring 的@Scheduled定时任务,配合每天凌晨跑一次的低库存扫描和效期扫描。扫描结果写入一张预警记录表,刷新看板时能直接显示。使用定时任务时注意加分布式锁,否则多实例部署时会重复执行,这是我线上出过一次的事故。
3. 后端实现:Spring Boot 2 项目骨架与 MyBatis-Plus 使用细节
3.1 先搭一个清爽的后端骨架
第一件事不是写业务代码,而是把项目结构定下来。我用的是常见的分层架构:
com.warehouse ├── common // 统一返回体、异常处理、工具类 ├── config // 配置文件类(分页插件、拦截器、CORS) ├── controller // 接收请求 ├── service // 业务逻辑 ├── mapper // MyBatis-Plus 的 Mapper 接口 ├── entity // 数据库实体 ├── dto // 前端传入的参数对象 └── vo // 返回给前端的数据对象统一返回体一定要从一开始就设计好。我习惯用Result<T>包含三个字段:
public class Result<T> { private int code; // 200 是成功,500 是业务异常 private String message; private T data; }配上全局异常处理器@RestControllerAdvice,业务代码里直接抛BusinessException("库位不存在")就能统一转换成这个格式,不用每个接口都写 try-catch。这是我强烈建议抄走的规范。
3.2 MyBatis-Plus 的配置与高效写法
MyBatis-Plus 在这个项目里的主力功能是这些:
- BaseMapper 内置方法:
selectById、insert、updateById、selectPage覆盖了大部分单表操作。 - LambdaQueryWrapper:写条件查询时能避免硬编码数据库字段名,重构表结构时不容易出 bug。
- 分页插件:必须手动配置,否则分页不生效。
- 自动填充:创建时间和更新时间用
@TableField(fill = FieldFill.INSERT)配合MetaObjectHandler自动填充,业务代码里不需要手动 set 时间字段。 - 乐观锁插件:用来处理并发更新库存这种关键操作。
分页插件的配置和使用方式,在项目中是一个必看的核心配置类,我直接贴出来:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }写分页查询时用 LambdaQueryWrapper 会有个小坑:如果你同时用了selectPage和逻辑删除字段,MP 会自动在 SQL 里追加deleted = 0的过滤条件,这本来是好事。但如果你在查询条件里又手动加了deleted字段的条件,就会导致 SQL 条件重复,某些复杂场景下会影响到查询性能。注意别重复添加类似条件。
3.3 并发扣减库存的正确姿势
扣库存是最容易出并发问题的场景,两个订单同时出库,一个批次明明只剩 10 件,结果两单各扣了 8 件,数据就错了。我实现时选的是“乐观锁 + 条件更新”方案。
核心思路:更新库存时,在 SQL 的where条件里带上当前库存数量,只有当库存数量仍然等于查询时的数量时才执行扣减。MyBatis-Plus 里可以这样写:
boolean success = stockService.lambdaUpdate() .eq(Stock::getId, stockId) .eq(Stock::getAvailableQty, expectQty) .setSql("available_qty = available_qty - " + outQty) .update(); if (!success) { throw new BusinessException("库存已变化,请重试"); }注意availableQty(可用数量)和lockedQty(锁定数量)要分开,出库单创建成功但还没发货时,先锁库存,真正发货完成后再扣减可用数量。在第一版项目里我没有做这个区分,结果月末盘点对不上账,后来增加了一个lockQty字段才解决。
实体类上要加@Version注解,配合前面配置的OptimisticLockerInnerInterceptor,UpdateById 时会自动带上版本号校验。这个方法虽然会增加重试逻辑的复杂度,但比直接用select for update锁行要好,因为它不会长时间占用数据库连接。
4. Vue3 前端设计与仓库看板实时刷新
4.1 前端项目从 Vite 到路由权限的组织方式
前端技术栈是 Vue3 + Vite + Element Plus + Pinia + Vue Router。这里特意选了 Pinia,因为它比 Vuex 简洁,不需要写那么多 mutation,写起来更贴合 Composition API 的习惯。
一个比较值得讲的设计是前端路由权限。典型做法是:登录后根据用户角色从后端拿到菜单列表,把这段菜单数据转换为路由对象,再用router.addRoute动态注册。这样可以实现“不同角色登录后看到的页面不同”。
注册动态路由的代码大致长这样:
const modules = import.meta.glob('../views/**/*.vue') // 解析菜单数据为 RouteRecordRaw[] const routes = menuList.map(item => ({ path: item.path, name: item.name, component: modules[`../views/${item.component}.vue`] }))最常见的问题出现在这里:动态添加路由后直接刷新页面,所有路由消失,因为 Pinia 里的菜单数据是内存态。解决的办法是在路由守卫里判断 store 里没有菜单信息时重新拉取菜单、重新注册路由,再next({ ...to, replace: true })。这套逻辑一定要写对,否则一刷新就白屏。
请求封装我习惯用 axios 实例,统一在拦截器里做两件事:从 Pinia 里拿 token 塞到请求头;收到 401 状态码时清理本地信息并跳转登录页。
service.interceptors.request.use(config => { const token = useUserStore().token if (token) config.headers.Authorization = `Bearer ${token}` return config }) service.interceptors.response.use( response => response.data.data, error => { if (error.response?.status === 401) { useUserStore().logout() router.push('/login') } return Promise.reject(error) } )4.2 Element Plus 表格和表单的高效封装
管理后台大部分页面都在做“表单 + 表格 + 分页”三件套,所以一定要封装公共组件。我封装了两个:SearchForm(负责查询条件表单)和ProTable(负责表格展示和分页)。
ProTable接收两个核心 props:columns描述列配置,api为获取列表数据的函数。封装后的页面代码能省掉三分之二,而且页面与页面之间的交互逻辑高度一致,新人接手时上手成本低。
列配置的简单示例:
const columns = [ { label: '商品编码', prop: 'skuCode', width: 140 }, { label: '商品名称', prop: 'skuName', minWidth: 180 }, { label: '库位编码', prop: 'locationCode', width: 120 }, { label: '库存数量', prop: 'qty', width: 100, sortable: true }, { label: '操作', slot: 'actions', width: 150 } ]4.3 大屏看板:WebSocket 推送实时库存
仓库看板是这个项目最有“智能感”的页面。看板不需要用户点击刷新,它要做的是像监控大屏一样自动变化。我在项目里用 WebSocket 把后端库存变化实时推送到页面。
后端用 Spring Boot 内置的 WebSocket,在库存变化后推送一条 JSON 消息到指定频道。前端在组件挂载时建立连接,收到消息后更新页面数据:
const socket = new WebSocket(`ws://${location.host}/ws/dashboard`) socket.onmessage = (event) => { const message = JSON.parse(event.data) if (message.type === 'stock_change') refreshStockSummary() }注意在onBeforeUnmount里一定要关闭连接,否则切换页面后连接没有释放,浏览器会一直维持无效的连接,积少成多会对后端造成连接数压力。
看板页面用 ECharts 渲染柱状图和饼图,主要展示库位占用率、库存总量、今日出入库数量、库存预警列表。如果你不想引入重量级图表库,也可以用纯 CSS 做进度条和数字卡片,但仓库系统通常还是值得上 ECharts,因为对大数据量的性能更好,效果也更专业。
5. 数据库设计要点与 MySQL 8.0 部署
5.1 核心表结构设计思想
这里以库位表为例,展示建表时值得注意的字段约定:
CREATE TABLE `wms_location` ( `id` bigint NOT NULL COMMENT '主键ID', `warehouse_code` varchar(32) NOT NULL COMMENT '仓库编码', `area_code` varchar(32) NOT NULL COMMENT '库区编码', `location_code` varchar(64) NOT NULL COMMENT '库位编码', `location_type` varchar(16) DEFAULT 'NORMAL' COMMENT '库位类型:NORMAL-普通,COLD-冷藏', `is_occupied` tinyint(1) DEFAULT 0 COMMENT '是否占用:0-空,1-占用', `status` tinyint(1) DEFAULT 1 COMMENT '状态:1-启用,0-停用', `version` int DEFAULT 0 COMMENT '乐观锁版本', `deleted` tinyint(1) DEFAULT 0 COMMENT '逻辑删除:0-正常,1-删除', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_location` (`warehouse_code`, `location_code`), KEY `idx_area_code` (`area_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库位表';三个细节值得解释一下:
- 主键不用自增,用 MyBatis-Plus 的雪花算法生成
bigint类型。这样在分布式环境下不容易冲突,也能避免自增主键被猜到业务数据量。 - 逻辑删除字段
deleted和@TableLogic配合使用,但要注意拥有唯一约束的字段。在 MySQL 里,(warehouse_code, location_code)建立唯一索引时,逻辑删除后同一库位再次插入会违反唯一约束。实际项目里我换成了在插入前先查一条已删除的记录,如果存在就把它的deleted改回 0 并更新数据,否则插入新记录。 update_time用ON UPDATE CURRENT_TIMESTAMP来自动维护,但注意 MyBatis-Plus 自动填充也可能覆盖它,不要双写。
5.2 MySQL 8.0 安装与连接时的几个坑
MySQL 8.0 相比 5.7 在连接层面最大的变化:驱动类名变成了com.mysql.cj.jdbc.Driver,连接 URL 要显式指定时区,不然会报时区错误。下面是我在application.yml里放的标准配置:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5两个连接参数非常关键:
serverTimezone=Asia/Shanghai:不指定的话,连接时经常会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,这是因为 MySQL 8.0 和驱动间的时区推断不一致。allowPublicKeyRetrieval=true:MySQL 8.0 默认使用 caching_sha2_password 认证插件,如果服务器端没有提前交换公钥,连接时会出现Public Key Retrieval is not allowed。
如果是在 Linux 服务器上用 Docker 安装,命令可以写成这样:
docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e TZ=Asia/Shanghai \ -v mysql-data:/var/lib/mysql \ mysql:8.0注意加上TZ=Asia/Shanghai,否则容器默认 UTC 时间,数据库里存的时间会比北京时间差 8 小时。这个小问题排查起来特别烦人,因为从代码角度完全没有报错,但看板上的“今日入库量”就是不对。
5.3 库存准确性:唯一约束、逻辑删除与索引的配合
为了库存准确,我总结了几个必须落地的约束:
- 流水表必加唯一约束:出入库流水、盘点记录这种只增不改的表,建议在业务上自然产生唯一编码的字段上建唯一索引,防止重复提交导致双写。
- 库存表联合唯一:
sku_code + location_code + batch_no建立唯一索引。没有批次号,同一个库位放两个不同生产日期的同商品会直接覆盖,这绝对是灾难。 - 查询性能靠索引:出入库明细表的查询条件通常是单据状态、商品编码、日期范围,这三个字段建立联合索引
(order_id, sku_code, create_time)能覆盖绝大多数查询场景。仓库系统的表数据量增长很快,索引的收益远大于加机器。
6. 从源码到运行:环境搭建与部署实录
6.1 后端跑起来:IDEA + Maven 多环境配置
项目文档里写了详细的启动步骤,核心部分可以归纳为三步:
- 先用 MySQL 客户端执行项目里的
sql/warehouse_init.sql,创建数据库和数据表,同时初始化了 admin 账号。 - 在 IDEA 里导入根目录的
pom.xml,等待 Maven 下载依赖。注意要配好 Maven 仓库镜像,如果你第一次跑项目时发现卡在下载mysql-connector-j之类的包,多半是没配阿里云镜像。 - 修改
application-dev.yml里的数据库账号密码,启动WarehouseApplication,后端默认端口是 8080。
Maven 多环境配置用spring.profiles.active区分 dev 和 prod,开发和部署用不同配置,密钥这类敏感信息绝不提交到代码仓库。
6.2 前端跑起来:Node 环境与 Vite 代理
前端目录是web/,依赖 Node 16 以上。启动顺序是:
npm install npm run devnpm install有几个常见问题:网络不好导致安装失败,这时候推荐用镜像源;如果项目里锁定了依赖版本但本地 Node 版本不对,会出现ERR_OSSL_EVP_UNSUPPORTED,这是 Webpack 时代的老问题,Vite 项目里相对少见但仍然存在,升级 Node 到 18+ 基本能解决。
Vite 开发环境下需要通过代理来解决跨域问题。在vite.config.ts里设置:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端代码中请求/api/login时,开发环境会自动代理到后端 8080 端口,不需要后端做跨域配置。
6.3 Nginx 部署与打包优化
生产环境部署时,前后端是分开部署的:后端打包成 jar 直接跑java -jar,前端打包出来的静态文件交给 Nginx。
前端打包命令是npm run build,产物在dist目录。需要注意两点:
- 如果后端接口路径以
/api开头,那么打包时VITE_API_BASE_URL这个环境变量要设置成/api。 - 如果 Vue Router 用的是 history 模式,刷新二级页面时会请求不存在的静态路径,导致 404。Nginx 里必须配置:
location / { root /opt/warehouse/web; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }try_files这一行就是解决 history 路由刷新白屏的关键。很多新人在本地开发时没这个问题,部署上线后才发现页面刷新就 404,原因就是少了这一行。
6.4 项目文档里都有什么
这套源码附带的文档我认为做得比较良心的是这三份:
- 数据库初始化脚本:建库、建表、初始化基础数据(菜单、角色、管理员账号、示例库位)一次执行完。
- 接口文档:以 Markdown 为主,按模块列清楚了每个接口的路径、请求参数、返回结构,并用 Postman 导出了一份可直接导入的集合。
- 部署说明:从 JDK 安装到 MySQL 建库再到 Nginx 部署,按顺序列操作步骤。
拿到这套源码时建议先看部署说明,跟着把环境跑起来,然后对着接口文档把每个模块的接口调一遍,再去读核心代码。先跑通再读代码的效率和体验,远好于直接翻源码。
7. 常见问题排查与避坑速查表
我把开发这个项目过程中遇到过的、或者读者大概率会遇到的七类典型问题整理成一个速查表。每一条背后的原因和解决办法,我在表格里说明清楚,方便你对照排查。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 启动后端报服务器时间无法识别 | MySQL 8.0 时区信息与驱动推断不匹配 | JDBC URL 加serverTimezone=Asia/Shanghai |
| 连接数据库报 Public Key Retrieval not allowed | MySQL 8.0 默认认证插件缓存公钥机制导致 | JDBC URL 加allowPublicKeyRetrieval=true |
| 列表数据总查询不到某些“已删除”的记录 | 逻辑删除字段被误用于业务过滤逻辑 | 检查 SQL 里是否重复添加 deleted 条件 |
| 前端表单提交后 Long 类型 ID 最终几位变成 0 | JS Number 精度不够,超过 2^53 丢失精度 | 在 Jackson 中把 Long 转 String,或者返回 VO 时转成字符串 |
| Vue3 页面刷新后 404 | 路由 history 模式 + Nginx 没有回退配置 | Nginx 增加try_files $uri $uri/ /index.html |
| WebSocket 连接断开后重连频繁 | 未处理断线重连与心跳 | 封装 ws 类,断线后指数退避重连,定时发送心跳包 |
| 扣库存偶尔出现负数 | 未使用乐观锁或无条件的 update set 语句 | 使用 MyBatis-Plus 乐观锁插件 + 条件更新 |
其中“前端 Long 精度丢失”这个问题我要特别提一下。数据库主键如果是雪花 ID(19 位数字),返回给前端时会被当作 JavaScript 的 Number 处理。超过Number.MAX_SAFE_INTEGER后,后几位会变成 0。最初我排查了很久才发现所有“找不到记录”问题的根源都在这,最后通过 Jackson 全局把 Long 转成 String 解决:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder -> { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }这个方法唯一的副作用是前端所有 Long 字段都会变成字符串,需要统一处理,但如果你的表格列不需要做数字运算的话,这个方案是可行的。如果你后续要走微服务拆分的路线,这个坑在服务间调用时还会遇到一次,早点规避能省很多沟通成本。
还有一条经验值得一提:Unified 的 WebSocket 连接安全问题。实际项目中不能只建立裸连接,要在建立连接时带上 token 参数并在后端校验,否则任何知道地址的人都能看到仓库动态。第一版项目图省事没加,后来在安全审计时被指出风险,第二版才补上。
最后分享两个关于这套项目的扩展想法
我自己在实际开发中体会最深的一点是:仓库管理系统的价值不在于把页面做得有多花哨,而是在库位策略和预警规则上。如果只是把 Excel 换成系统,本质上没有多大提升。真正把它变成“智能”系统的分水岭,是系统能否自动告诉仓管员“这箱货应该放哪、先去拣哪批货、有什么快过期了”。所以拿到这套源码后,不要急着在这些策略上照抄,先要到业务现场蹲半天,记录仓库日常是怎么流转的,然后照着真实流程调整库位分配规则。
另外一个小技巧:如果你需要生成测试数据,可以写一个简单的数据初始化脚本,按批次生成商品和库位数据,一次性插入几百条测试记录。别小看这个步骤,库存盘点、预警、看板的页面效果,没有大量真实分布的数据是看不出效果的。项目文档里给了一部分示例数据,自己在本地再加大数据量试,会更容易理解整个系统的调度逻辑。
扩展方向上,可以加入对接扫码枪的入出库确认流程,让仓库操作员不用打开电脑,直接在手持终端上完成操作。这个项目目前的扫码逻辑是用文本框监听键盘事件模拟扫码枪输入,原理是用“前缀+回车”识别一把完整扫描结果,后续可以无缝升级成蓝牙扫码枪或 PDA 端配套。从这个点入手,就能把“无人化”再往前推一步。