温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系名片!
一、项目背景与目标
仓库管理系统(WMS,Warehouse Management System)是电商、制造、零售等行业的基础设施,负责商品入库、出库、库存盘点、库位管理等核心业务。传统单体系统在业务增长后往往面临扩展困难、前后端耦合严重、部署效率低等问题。
本文基于Spring Boot 3 + Vue 3前后端分离架构,实现一套可运行的仓库管理系统,覆盖商品管理、入库管理、出库管理、库存查询与预警、用户权限控制等核心模块,并给出关键代码与部署方案。
阅读本文后,你将掌握:前后端分离项目的工程结构划分、基于 JWT 的认证授权、库存扣减的并发控制思路,以及 Docker 化部署的完整流程。
二、技术选型与整体架构
2.1 技术栈
| 层次 | 技术选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 3.2 | 基于 JDK 17,提供自动装配与内嵌容器 |
| 持久层 | MyBatis-Plus 3.5 | 简化 CRUD,支持分页与逻辑删除 |
| 数据库 | MySQL 8.0 | 存储业务数据,使用 InnoDB 事务 |
| 缓存 | Redis 7 | 缓存库存热点数据、存储 Token 黑名单 |
| 认证 | Spring Security + JWT | 无状态认证,支持角色权限控制 |
| 前端框架 | Vue 3 + TypeScript | 组合式 API,类型安全 |
| UI 组件库 | Element Plus | 后台管理系统常用组件 |
| 构建工具 | Vite 5 | 快速冷启动与热更新 |
| 状态管理 | Pinia | 替代 Vuex,更轻量的状态管理 |
2.2 系统架构
flowchart LR A[浏览器 Vue3 SPA] -->|HTTP/JSON + JWT| B[Nginx 静态资源与反向代理] B --> C[Spring Boot 应用] C --> D[(MySQL 8.0)] C --> E[(Redis 7)] C --> F[文件存储 OSS/本地]前端通过 Nginx 托管静态资源,并将/api前缀请求反向代理到后端服务;后端采用分层结构:Controller → Service → Mapper,统一异常处理与统一响应体封装。
三、数据库设计
核心表设计遵循「主数据 + 单据 + 流水」的思路,库存数量不直接冗余在商品表,而是通过库存流水汇总,保证可追溯。
-- 商品表 CREATE TABLE `product` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `code` VARCHAR(64) NOT NULL COMMENT '商品编码', `name` VARCHAR(128) NOT NULL COMMENT '商品名称', `category_id` BIGINT DEFAULT NULL COMMENT '分类ID', `unit` VARCHAR(16) DEFAULT '件' COMMENT '单位', `price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '参考单价', `status` TINYINT DEFAULT 1 COMMENT '1启用 0停用', `deleted` TINYINT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存表(按商品 + 库位维度) CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_code VARCHAR(64) DEFAULT NULL COMMENT '库位编码', quantity INT NOT NULL DEFAULT 0 COMMENT '可用库存', locked_quantity INT NOT NULL DEFAULT 0 COMMENT '锁定库存', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_product_wh_loc (product_id,warehouse_id,location_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 出入库单据表 CREATE TABLE stock_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '单号', type TINYINT NOT NULL COMMENT '1入库 2出库', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已完成 2已取消', operator_id BIGINT NOT NULL, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 单据明细表 CREATE TABLE stock_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, KEY idx_order_id (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;四、后端核心实现
4.1 工程结构
wms-server ├── wms-common # 通用工具、统一响应、异常 ├── wms-security # Spring Security + JWT ├── wms-module-product # 商品模块 ├── wms-module-stock # 库存与出入库模块 └── wms-boot # 启动模块与配置4.2 统一响应与全局异常处理
@Data public class R<T> implements Serializable { private int code; private String message; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> R<T> fail(int code, String message) { R<T> r = new R<>(); r.code = code; r.message = message; return r; } }@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public R<Void> handleBiz(BizException e) { return R.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public R<Void> handleValid(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining("; ")); return R.fail(400, msg); } @ExceptionHandler(Exception.class) public R<Void> handleOther(Exception e) { log.error("系统异常", e); return R.fail(500, "系统繁忙,请稍后重试"); } }4.3 JWT 认证过滤器
@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; private final UserDetailsService userDetailsService; private final StringRedisTemplate redisTemplate; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { String token = header.substring(7); // 校验是否已登出 if (Boolean.TRUE.equals(redisTemplate.hasKey("jwt:blacklist:" + token))) { chain.doFilter(request, response); return; } try { String username = jwtUtils.getUsername(token); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails user = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } } catch (JwtException e) { log.warn("JWT 校验失败: {}", e.getMessage()); } } chain.doFilter(request, response); } }4.4 库存扣减:乐观锁 + 重试
出库场景下并发扣减库存是典型问题。这里采用「乐观锁版本号 + 有限重试」方案,避免直接使用悲观锁带来的吞吐下降。
@Service @RequiredArgsConstructor public class StockService { private final StockMapper stockMapper; private static final int MAX_RETRY = 3; @Transactional(rollbackFor = Exception.class) public void deduct(Long productId, Long warehouseId, int quantity) { for (int i = 0; i < MAX_RETRY; i++) { Stock stock = stockMapper.selectByProductAndWarehouse(productId, warehouseId); if (stock == null) { throw new BizException(404, "库存记录不存在"); } if (stock.getQuantity() - stock.getLockedQuantity() < quantity) { throw new BizException(400, "库存不足,当前可用:" + (stock.getQuantity() - stock.getLockedQuantity())); } int rows = stockMapper.deductWithVersion( stock.getId(), quantity, stock.getVersion()); if (rows > 0) { return; } log.warn("库存扣减冲突,第 {} 次重试, productId={}", i + 1, productId); } throw new BizException(409, "库存扣减失败,请稍后重试"); } }<update id="deductWithVersion"> UPDATE stock SET quantity = quantity - #{quantity}, version = version + 1 WHERE id = #{id} AND version = #{version} AND quantity - locked_quantity >= #{quantity} </update>五、前端核心实现
5.1 请求封装与 Token 注入
import axios, { AxiosInstance } from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' const request: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) request.interceptors.request.use((config) => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = Bearer ${userStore.token} } return config }) request.interceptors.response.use( (response) => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, (error) => { if (error.response?.status === 401) { useUserStore().logout() router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request5.2 库存列表页(组合式 API)
<template> <el-card> <el-form :inline="true" :model="query"> <el-form-item label="商品名称"> <el-input v-model="query.keyword" placeholder="编码或名称" clearable /> </el-form-item> <el-form-item> <el-button type="primary" @click="loadData">查询</el-button> </el-form-item> </el-form> <el-table :data="list" v-loading="loading" border> <el-table-column prop="productCode" label="商品编码" width="160" /> <el-table-column prop="productName" label="商品名称" /> <el-table-column prop="warehouseName" label="仓库" width="140" /> <el-table-column prop="quantity" label="可用库存" width="120"> <template #default="{ row }"> <el-tag :type="row.quantity < row.warnLine ? 'danger' : 'success'"> {{ row.quantity }} </el-tag> </template> </el-table-column> <el-table-column prop="updateTime" label="更新时间" width="180" /> </el-table> <el-pagination class="pager" v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="loadData" /> </el-card> </template> <script setup lang="ts"> import { onMounted, reactive, ref } from 'vue' import request from '@/utils/request' interface StockVO { productCode: string productName: string warehouseName: string quantity: number warnLine: number updateTime: string } const loading = ref(false) const list = ref<StockVO[]>([]) const total = ref(0) const query = reactive({ keyword: '', page: 1, size: 10 }) async function loadData() { loading.value = true try { const data = await request.get('/api/stock/page', { params: query }) list.value = data.records total.value = data.total } finally { loading.value = false } } onMounted(loadData) </script> <style scoped> .pager { margin-top: 16px; justify-content: flex-end; } </style>六、关键业务难点与解决方案
- 库存一致性:出库采用乐观锁重试,入库直接累加;跨仓库调拨使用事务包裹两条库存更新,保证原子性。
- 单据幂等:提交单据时以
order_no唯一索引兜底,前端按钮加防重复提交锁。 - 权限控制:基于 RBAC 模型,接口使用
@PreAuthorize("hasAuthority('stock:out')")注解校验。 - 库存预警:定时任务每日扫描低于预警线的库存,写入消息表并推送站内通知。
- 操作审计:通过 AOP 切面记录关键操作日志,包含操作人、IP、请求参数与耗时。
七、部署方案
7.1 Docker Compose 编排
version: "3.8" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: wms ports: - "3306:3306" volumes: - ./sql:/docker-entrypoint-initdb.d - mysql-data:/var/lib/mysql redis: image: redis:7-alpine ports: - "6379:6379" server: build: ./wms-server depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod ports: - "8080:8080" web: build: ./wms-web depends_on: - server ports: - "80:80" volumes: mysql-data:7.2 Nginx 配置
server { listen 80; server_name wms.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://server:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }八、总结与后续优化方向
本文从架构设计、数据库建模、后端认证与库存并发控制、前端工程化到容器化部署,完整梳理了一套基于 Spring Boot 3 + Vue 3 的仓库管理系统实现路径。核心思路是:主数据与流水分离、并发扣减用乐观锁、认证无状态化、部署容器化。
后续可继续扩展的方向包括:
- 引入消息队列(RocketMQ / RabbitMQ)解耦库存变更事件,支撑异步对账。
- 增加批次与效期管理,支持先进先出(FIFO)出库策略。
- 接入报表引擎,输出库存周转率、呆滞库存等经营指标。
- 前端引入虚拟滚动与表格列配置,提升大数据量下的交互体验。