ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot+Vue的库存管理系统实战:从建表到防超卖全链路

基于SpringBoot+Vue的库存管理系统实战:从建表到防超卖全链路 简介本资源是一套基于SpringBootVue的库存管理系统完整项目包面向计算机专业学生、Java初学者及需要完成毕业设计或课程设计的人群可帮助解决选题无思路、项目难落地、代码看不懂等问题。压缩包共431个文件约21.12MB以117个Java后端源码、60个Vue前端组件、161个svg图标资源、17个js脚本及19张png、17张jpg图片为主另含xml配置、sql数据库脚本、bat启动脚本与少量音视频素材前后端代码与数据库脚本齐全并附有代码注释新手也能看懂。项目经过严格调试部署后即可运行后台与前台页面路径清晰开发环境为IDEA数据库建议使用MySQL 5.7配合Navicat与Maven、Tomcat即可完成搭建。目前已有63人学习关注适合作为高分毕业设计、期末大作业或课程设计参考也可用于学习SpringBoot与Vue前后端分离开发的实际应用。1. 基于 SpringBoot Vue 的库存管理系统一套能直接跑起来的前后端分离骨架库存管理这件事做过的人都知道难点从来不在“增删改查”四个字而在“入库、出库、盘点、预警”这几条业务线怎么串起来还要保证库存数量在并发下不出错。这套基于 SpringBoot Vue 的库存管理系统本质上是给中小型仓储、门店、工厂库房提供一套前后端分离的落地骨架后端用 SpringBoot 提供 REST 接口前端用 Vue 做单页交互数据库存商品、仓库、出入库单据和库存流水。它适合两类人——想拿一个完整 Java 项目练手、把 SpringBoot 和 Vue 串通的新手以及需要快速搭一套内部库存工具、不想从零写权限和分页的工程师。下面我按“先跑通、再改业务、最后避坑”的顺序把选型理由、建表、接口、联调和踩坑一次讲清。2. 技术选型与项目骨架为什么是 SpringBoot Vue 而不是别的组合2.1 后端选 SpringBoot 的三个现实理由库存系统的后端要处理的是典型的“事务 分页 权限”三件套。SpringBoot 在这三件事上都有现成方案事务用Transactional声明式管理分页用 MyBatis-Plus 的分页插件权限用 Spring Security 或简单的拦截器加 JWT 就能覆盖。相比传统 SSM 要写一堆 XML 配置SpringBoot 的自动配置让一个库存项目能在半小时内把 Web 层、数据层、事务层全部拉起来。另一个理由是生态。库存系统绕不开“单据编号生成、库存扣减、操作日志”这些横切逻辑SpringBoot 的 AOP 和自定义注解能把这些从业务代码里抽出来。比如出库时扣库存你可以在 Service 方法上加一个自定义注解用切面统一处理库存流水记录业务代码只关心“扣多少”。第三个理由是部署。库存系统通常部署在内网服务器上SpringBoot 打成可执行 jar配合application-prod.yml切换数据库连接运维不需要装 Tomcatjava -jar就能起。这一点在给客户做私有化部署时特别省事。2.2 前端选 Vue 而不是模板引擎的考量库存系统的界面有大量表格、表单、弹窗和联动筛选用 Thymeleaf 这类模板引擎写页面一复杂就变成“拼接字符串地狱”。Vue 的组件化能把“商品选择器”“仓库下拉”“出入库明细表格”拆成独立组件复用数据双向绑定也让表单校验省掉大量 DOM 操作。具体到版本新手建议直接用 Vue 3 Vite Element Plus。Vue 3 的组合式 API 在库存这种“一个页面多个数据源”的场景下比选项式更清晰Vite 的冷启动和热更新速度比 Webpack 快一个量级改一行表格列宽几乎秒级生效。Element Plus 的el-table、el-form、el-dialog直接覆盖了库存系统 80% 的 UI 需求不用自己造轮子。2.3 项目目录结构怎么摆才不乱一个能长期维护的库存项目目录结构要按“职责”分而不是按“文件类型”分。后端我一般这样摆inventory-backend/ ├── src/main/java/com/example/inventory/ │ ├── controller/ # 只做参数校验和调用 Service │ ├── service/ # 业务逻辑事务边界在这一层 │ │ └── impl/ │ ├── mapper/ # MyBatis-Plus Mapper 接口 │ ├── entity/ # 数据库实体和表一一对应 │ ├── dto/ # 前端传入的参数对象 │ ├── vo/ # 返回给前端的视图对象 │ ├── config/ # 跨域、拦截器、MyBatis-Plus 配置 │ └── common/ # 统一返回体、异常、工具类 └── src/main/resources/ ├── application.yml └── mapper/ # 复杂 SQL 的 XML简单查询用注解前端对应这样inventory-frontend/ ├── src/ │ ├── api/ # 按模块拆的接口封装 │ ├── views/ # 页面级组件 │ ├── components/ # 可复用组件商品选择器、分页表格 │ ├── router/ # 路由和权限守卫 │ ├── store/ # Pinia 状态管理 │ └── utils/ # axios 封装、日期格式化 └── vite.config.js这样分的好处是新人接手时找接口去api/找页面去views/找公共逻辑去utils/不会在几百个文件里翻。库存系统的业务模块通常就四块——基础数据商品、仓库、供应商、入库、出库、库存查询与预警前端views下按这四块建文件夹后端controller也按这四块分前后端目录能对上联调时沟通成本低很多。3. 数据库设计与核心表库存数量到底存在哪张表3.1 库存系统最少需要哪几张表库存系统的表可以多到几十张但核心就五张商品表、仓库表、库存表、入库单主表 明细表、出库单主表 明细表。这里有个关键设计决策——库存数量不要存在商品表里。商品表只存商品的基础属性名称、规格、单位、分类库存数量单独放一张stock表按“商品 仓库”维度存。为什么因为同一个商品可能放在多个仓库如果数量存在商品表你就没法区分“A 仓库有 10 件、B 仓库有 5 件”。而且商品表和库存表的更新频率完全不同商品信息可能一个月改一次库存数量每分钟都在变拆开能减少锁冲突。下面是最小可用的建表 SQLMySQL 8.0 语法-- 商品表只存基础属性 CREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 商品名称, spec VARCHAR(100) DEFAULT NULL COMMENT 规格, unit VARCHAR(20) DEFAULT 件 COMMENT 单位, category_id BIGINT DEFAULT NULL COMMENT 分类ID, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 仓库表 CREATE TABLE warehouse ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 仓库名称, address VARCHAR(200) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT仓库表; -- 库存表商品仓库维度唯一索引防重复 CREATE TABLE stock ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0 COMMENT 当前库存数量, warn_threshold INT DEFAULT 0 COMMENT 预警阈值, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_product_warehouse (product_id, warehouse_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表;stock表上的uk_product_warehouse唯一索引是必须的。没有它并发入库时可能给同一个商品同一个仓库插入两条库存记录后面扣减就乱了。有了唯一索引插入重复数据会直接报错你可以在代码里捕获后改成UPDATE。3.2 入库单和出库单的表结构差异入库单和出库单结构相似但业务含义不同建议分成两组表而不是一张表加类型字段。分开的好处是字段可以各自扩展——入库单可能需要“供应商”“采购单号”出库单可能需要“领用人”“用途”。如果硬塞一张表字段会越来越杂查询时到处WHERE type ?。-- 入库单主表 CREATE TABLE inbound_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 单据编号, warehouse_id BIGINT NOT NULL, supplier VARCHAR(100) DEFAULT NULL, total_quantity INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0草稿 1已入库 2已作废, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT入库单主表; -- 入库单明细 CREATE TABLE inbound_item ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT入库单明细;出库单把inbound换成outbound字段基本一致。单据编号order_no上加唯一索引防止重复提交生成两张单。编号生成规则常见做法是“前缀 日期 序列”比如IN20250101001用 Redis 自增或者数据库序列表都行小项目直接用SELECT MAX加锁也能凑合但并发高了会翻车。3.3 库存扣减为什么必须放在事务里出库的核心动作是“写一条出库明细 扣减 stock 表数量”。这两步必须在一个事务里否则可能出现“明细写了但库存没扣”或者“库存扣了但明细没写”的脏数据。SpringBoot 里在 Service 方法上加Transactional(rollbackFor Exception.class)就能保证。但光有事务还不够。如果两个请求同时出库同一个商品事务默认的隔离级别下可能都读到库存 10然后各自扣 3最后库存变成 7 而不是 4。解决办法是在扣减 SQL 里用条件更新UPDATE stock SET quantity quantity - #{num} WHERE product_id #{pid} AND warehouse_id #{wid} AND quantity #{num}这条 SQL 的quantity #{num}是关键它让数据库在行锁层面保证“库存不足时更新影响行数为 0”代码里判断影响行数为 0 就抛异常回滚。这比先SELECT再UPDATE可靠得多也是库存系统最常见的防超卖写法。4. 后端接口实现从商品分页到出库扣库存的完整链路4.1 用 MyBatis-Plus 写商品分页查询商品列表是库存系统里访问最频繁的接口要支持按名称模糊查、按分类筛选、分页。MyBatis-Plus 的分页插件能省掉手写LIMIT的麻烦。先加配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件指定数据库类型为 MySQL interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后 Service 里这样写Service public class ProductServiceImpl extends ServiceImplProductMapper, Product implements ProductService { Override public PageProduct pageQuery(int pageNum, int pageSize, String name, Long categoryId) { LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); // 名称模糊匹配categoryId 精确匹配条件为空时不拼接 wrapper.like(StringUtils.hasText(name), Product::getName, name) .eq(categoryId ! null, Product::getCategoryId, categoryId) .orderByDesc(Product::getCreateTime); return this.page(new Page(pageNum, pageSize), wrapper); } }LambdaQueryWrapper的好处是用方法引用代替字符串字段名重构时改字段名编译器会报错不会出现“改了实体类但 XML 里字段名没改”的玄学问题。like和eq的第一个参数是条件布尔值为 false 时该条件不拼进 SQL这样一套代码能覆盖“不带筛选”和“带筛选”两种情况。Controller 层只做参数接收和返回封装RestController RequestMapping(/api/product) public class ProductController { Autowired private ProductService productService; GetMapping(/page) public ResultPageProduct page( RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String name, RequestParam(required false) Long categoryId) { return Result.ok(productService.pageQuery(pageNum, pageSize, name, categoryId)); } }Result是统一返回体包含code、msg、data三个字段。前端 axios 拦截器里判断code是否为 200不是就弹错误提示。这样后端抛异常时前端能统一处理不用每个接口单独写错误分支。4.2 出库接口扣库存和写流水的顺序出库接口是整个系统最容易出 bug 的地方。我一般把逻辑写成三步校验单据状态、逐条扣减库存、更新单据状态。扣减库存时用前面说的条件更新每条明细扣减后判断影响行数。Service public class OutboundServiceImpl implements OutboundService { Autowired private StockMapper stockMapper; Autowired private OutboundOrderMapper orderMapper; Autowired private OutboundItemMapper itemMapper; Override Transactional(rollbackFor Exception.class) public void confirmOutbound(Long orderId) { OutboundOrder order orderMapper.selectById(orderId); // 只有草稿状态才能确认出库防止重复提交 if (order null || order.getStatus() ! 0) { throw new BizException(单据状态不允许出库); } ListOutboundItem items itemMapper.selectByOrderId(orderId); for (OutboundItem item : items) { // 条件更新库存不足时 affected 0 int affected stockMapper.deductStock( item.getProductId(), order.getWarehouseId(), item.getQuantity()); if (affected 0) { throw new BizException(商品[ item.getProductId() ]库存不足); } } order.setStatus(1); orderMapper.updateById(order); } }对应的 Mapper 方法Update(UPDATE stock SET quantity quantity - #{num} WHERE product_id #{pid} AND warehouse_id #{wid} AND quantity #{num}) int deductStock(Param(pid) Long pid, Param(wid) Long wid, Param(num) int num);这里有个顺序问题先扣库存还是先改单据状态我的经验是先扣库存最后改状态。因为扣库存失败会抛异常回滚单据状态不会变如果先改状态再扣库存扣失败回滚时状态也会回滚逻辑上没错但代码可读性差。另外Transactional默认只对RuntimeException回滚所以自定义的BizException要继承RuntimeException或者显式写rollbackFor Exception.class。4.3 库存预警查询怎么写才不慢库存预警是“查所有库存低于阈值的记录”听起来简单但如果库存表有几十万行WHERE quantity warn_threshold会全表扫描。优化办法是给quantity和warn_threshold建联合索引不行因为这是两个列的比较索引用不上。实际做法是预警阈值通常不会频繁变可以在stock表加一个冗余字段is_warn每次库存变动时更新这个字段quantity warn_threshold时置 1否则置 0然后给is_warn建索引。查询就变成WHERE is_warn 1走索引快很多。代价是每次扣减库存后要多一次判断更新但库存变动频率远低于查询频率这笔账划算。// 扣减库存后同步更新预警标记 Update(UPDATE stock SET quantity quantity - #{num}, is_warn IF(quantity - #{num} warn_threshold, 1, 0) WHERE product_id #{pid} AND warehouse_id #{wid} AND quantity #{num}) int deductStockWithWarn(Param(pid) Long pid, Param(wid) Long wid, Param(num) int num);这样一条 SQL 同时完成扣减和预警标记更新不用额外查询。IF函数是 MySQL 的其他数据库换成CASE WHEN即可。5. 前后端联调避坑跨域、打包和那些让人后悔药的细节5.1 跨域配置为什么本地好了线上又挂开发阶段前端跑在localhost:5173后端跑在localhost:8080浏览器同源策略会拦请求。常见做法是后端加 CORS 配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) // 注意用 patterns 而不是 origins .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }这里有个血泪经验allowedOrigins(*)和allowCredentials(true)不能同时用SpringBoot 会直接报错。必须用allowedOriginPatterns(*)。另外如果前端 axios 设置了withCredentials: true带 cookie后端这个allowCredentials也必须为 true否则 cookie 传不过去。线上环境更推荐用 Nginx 反向代理前端静态资源和后端接口同域从根上避免跨域。Nginx 配置大概这样server { listen 80; location / { root /usr/share/nginx/html; # Vue 打包后的 dist 目录 try_files $uri $uri/ /index.html; # 解决刷新 404 } location /api/ { proxy_pass http://127.0.0.1:8080/; } }try_files那行是必须的否则 Vue 路由刷新页面会 404因为 Nginx 找不到/product/list这样的物理路径。5.2 Vue 打包放进 SpringBoot 的两种方式如果不想单独部署 Nginx可以把 Vue 打包后的dist目录放进 SpringBoot 的src/main/resources/static下SpringBoot 会自动作为静态资源提供。但要注意Vue Router 用 history 模式时刷新非根路径会 404需要在 SpringBoot 里加一个转发配置Controller public class SpaController { // 所有非 api 开头的路径都转发到 index.html RequestMapping(value /{path:[^\\.]*}) public String forward() { return forward:/index.html; } }这种方式适合小项目或演示缺点是每次改前端都要重新打包放进后端前后端耦合。更推荐前后端分开部署用 Nginx 统一入口前端改完直接替换dist目录不用动后端。5.3 常见问题排查清单现象前端请求返回 401 但登录接口正常。原因通常是 JWT token 没带上或者拦截器把登录接口也拦了。解决检查 axios 请求拦截器是否在 header 里加了Authorization后端拦截器是否放行了/api/auth/login。现象出库时提示库存不足但数据库里明明有货。原因多半是stock表里没有这个“商品 仓库”的记录UPDATE影响行数为 0。解决入库时用INSERT ... ON DUPLICATE KEY UPDATE保证记录存在或者出库前先查一次库存记录是否存在。现象分页查询总数不对。MyBatis-Plus 分页插件没配置或者配置了但Page对象没传给page()方法。解决确认MybatisPlusConfig里加了PaginationInnerInterceptorService 里用的是this.page(page, wrapper)而不是this.list(wrapper)。现象Vue 打包后接口 404。开发时用了baseURL: /api打包后 Nginx 没配/api的代理。解决检查 Nginxlocation /api/的proxy_pass地址注意结尾斜杠——proxy_pass http://127.0.0.1:8080/会把/api/product转成/productproxy_pass http://127.0.0.1:8080则保留/api/product两者行为不同。现象单据编号重复。并发提交时两个请求拿到同一个MAX(order_no)。解决给order_no加唯一索引插入冲突时捕获异常重新生成或者用 Redis 的INCR生成序列天然原子。6. 让库存系统真正可用单据编号生成与并发压测的两个技巧6.1 单据编号生成别用时间戳硬拼很多教程教用System.currentTimeMillis()拼单据号这在单机低并发下能用但同一毫秒内两个请求就会撞号。更稳的做法是用“日期 Redis 自增”每天一个 key第二天自动从 1 开始Component public class OrderNoGenerator { Autowired private StringRedisTemplate redisTemplate; public String generate(String prefix) { String date LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String key order:no: prefix : date; // 第一次调用时设置过期时间为 2 天避免 key 堆积 Long seq redisTemplate.opsForValue().increment(key); if (seq ! null seq 1) { redisTemplate.expire(key, Duration.ofDays(2)); } return prefix date String.format(%04d, seq); } }INCR是原子操作多个请求同时来也不会拿到重复值。expire只在第一次设置避免每次调用都刷新过期时间导致 key 永不过期。如果项目不想引入 Redis用数据库的序列表加SELECT ... FOR UPDATE也行但性能差一个量级库存系统单据量不大时够用。6.2 用 JMeter 压测出库接口验证防超卖写完出库逻辑别急着上线先用 JMeter 压一轮。建一个线程组100 个线程循环 10 次总共 1000 次出库请求目标商品初始库存设 500。如果防超卖逻辑正确最终库存应该是 0成功出库 500 次失败 500 次且库存不会变成负数。压测时重点看两个指标响应时间和数据库连接池等待时间。如果响应时间随并发升高急剧上升多半是数据库连接池太小application.yml里把spring.datasource.hikari.maximum-pool-size调到 20 左右再试。如果出现大量Deadlock found异常说明扣减顺序不一致——两个事务一个先扣 A 再扣 B另一个先扣 B 再扣 A互相等锁。解决办法是在 Service 里对明细按product_id排序后再逐条扣减保证所有事务加锁顺序一致。// 按 productId 排序避免死锁 items.sort(Comparator.comparing(OutboundItem::getProductId)); for (OutboundItem item : items) { // 扣减逻辑... }这个排序操作看起来不起眼但在多商品出库场景下能消掉大部分死锁。我当初做第一个库存项目时没排序压测时数据库死锁日志刷屏排查了一下午才定位到算是花钱买的教训。6.3 一个验证库存一致性的对账 SQL系统跑一段时间后怎么确认库存没算错写一条对账 SQL用入库总量减出库总量和stock表的quantity对比SELECT s.product_id, s.warehouse_id, s.quantity AS current_stock, COALESCE(in_sum.total, 0) - COALESCE(out_sum.total, 0) AS calculated_stock FROM stock s LEFT JOIN ( SELECT i.product_id, o.warehouse_id, SUM(i.quantity) AS total FROM inbound_item i JOIN inbound_order o ON i.order_id o.id WHERE o.status 1 GROUP BY i.product_id, o.warehouse_id ) in_sum ON s.product_id in_sum.product_id AND s.warehouse_id in_sum.warehouse_id LEFT JOIN ( SELECT i.product_id, o.warehouse_id, SUM(i.quantity) AS total FROM outbound_item i JOIN outbound_order o ON i.order_id o.id WHERE o.status 1 GROUP BY i.product_id, o.warehouse_id ) out_sum ON s.product_id out_sum.product_id AND s.warehouse_id out_sum.warehouse_id WHERE s.quantity ! COALESCE(in_sum.total, 0) - COALESCE(out_sum.total, 0);这条 SQL 返回的行就是库存对不上的记录。正常情况下应该返回空结果。如果跑出来有数据优先查“已作废单据是否被算进去了”和“手工改过库存表没有流水记录”这两种情况。我习惯在每次版本上线前跑一遍这条 SQL确认库存数据干净再发布比事后被业务追着问“为什么库存少了”要从容得多。这套系统从建表到联调真正花时间的不是写代码而是想清楚“库存数量在并发下怎么保证正确”和“前后端部署怎么不互相拖累”。把这两件事做扎实剩下的增删改查都是体力活。希望帮到你。本文还有配套的精品资源点击获取
返回列表