Spring Boot + Vue3 智能无人仓库管理系统:库位分配、库存预警与部署实践

发布时间:2026/9/26 6:52:19
Spring Boot + Vue3 智能无人仓库管理系统:库位分配、库存预警与部署实践 这个项目我前后做了两个版本第一版只在技术上跑通了出入库第二版才真正把“无人化”的逻辑补全。下面我拆开讲这套基于 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 ViteVue3 的 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 // 返回给前端的数据对象统一返回体一定要从一开始就设计好。我习惯用ResultT包含三个字段public class ResultT { 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注解配合前面配置的OptimisticLockerInnerInterceptorUpdateById 时会自动带上版本号校验。这个方法虽然会增加重试逻辑的复杂度但比直接用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接收两个核心 propscolumns描述列配置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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5两个连接参数非常关键serverTimezoneAsia/Shanghai不指定的话连接时经常会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是因为 MySQL 8.0 和驱动间的时区推断不一致。allowPublicKeyRetrievaltrueMySQL 8.0 默认使用 caching_sha2_password 认证插件如果服务器端没有提前交换公钥连接时会出现Public Key Retrieval is not allowed。如果是在 Linux 服务器上用 Docker 安装命令可以写成这样docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDyourpassword \ -e TZAsia/Shanghai \ -v mysql-data:/var/lib/mysql \ mysql:8.0注意加上TZAsia/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 加serverTimezoneAsia/Shanghai连接数据库报 Public Key Retrieval not allowedMySQL 8.0 默认认证插件缓存公钥机制导致JDBC URL 加allowPublicKeyRetrievaltrue列表数据总查询不到某些“已删除”的记录逻辑删除字段被误用于业务过滤逻辑检查 SQL 里是否重复添加 deleted 条件前端表单提交后 Long 类型 ID 最终几位变成 0JS Number 精度不够超过 2^53 丢失精度在 Jackson 中把 Long 转 String或者返回 VO 时转成字符串Vue3 页面刷新后 404路由 history 模式 Nginx 没有回退配置Nginx 增加try_files $uri $uri/ /index.htmlWebSocket 连接断开后重连频繁未处理断线重连与心跳封装 ws 类断线后指数退避重连定时发送心跳包扣库存偶尔出现负数未使用乐观锁或无条件的 update set 语句使用 MyBatis-Plus 乐观锁插件 条件更新其中“前端 Long 精度丢失”这个问题我要特别提一下。数据库主键如果是雪花 ID19 位数字返回给前端时会被当作 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 端配套。从这个点入手就能把“无人化”再往前推一步。