Spring Boot库存管理系统实战:数据模型、事务与乐观锁并发控制
2026/9/16 16:07:52 网站建设 项目流程

简介:一套面向Java毕业设计与课程设计的库存管理系统项目,基于Spring Boot + Vue + MySQL实现B/S架构,覆盖公告信息、员工、供应商类型与信用等级、商品类型、供应商、商品预定、采购入库及客户等完整业务链路,功能划分清晰,适合需要落地同类系统或学习前后端分离开发的读者。压缩包共433个文件,以Java后端源码、Vue前端页面、SQL脚本为主,另含2个MP4演示视频、说明文档、启动脚本及样式资源,包体约35.93MB,导入IDEA配合Tomcat即可运行,目录结构便于按模块检索。资源已有97人浏览学习。借助完整源码可梳理Spring Boot接口设计与Vue页面联调的实际流程,SQL脚本可直接初始化数据库,演示视频能直观展示采购入库等核心模块的操作方式,说明文档则有助于撰写毕业设计论文与答辩准备,整体实用性较强。

1. 一份库存管理系统源码里,被忽略的启动链路

很多 Java 学习者拿到 Spring Boot 库存管理系统源码时,第一反应是打开src/main/java看实体类、看 Controller,结果被一堆分包绕晕。真正让毕业设计能快速跑起来的,往往不是业务代码,而是根目录下那几个不起眼的.bat脚本和前端的编译产物。这个项目是典型的 Spring Boot + Vue 前后端分离结构,后端用 Java 和 Spring Boot 提供 REST 接口,前端编译成静态资源后交给 Boot 统一托管,MySQL 负责数据落地。对打算做课程设计、毕业设计或者二次开发的人来说,它提供的不是一个只可观赏的源码包,而是一条从建库、启动到演示的完整闭环。理解这条链路,比多读十个 Controller 都有用。

2. 商品、供应商与入库单:库存系统的数据模型怎么拆

库存管理系统的核心不是前端页面,而是数据模型能不能支撑起“进、存、销、退”这几条业务线。拿到源码后先不要去纠结某个按钮的点击事件,把表结构理顺,后面所有接口都会变得好懂。

2.1 从实体关系图到核心业务表

项目摘要里提到的对象有公告信息、员工、供应商类型、供应商信用等级、商品类型、公告类型、供应商、商品、商品预定、采购入库、采购入库详情、客户。拆开看,这些对象并不是平等关系,而是被类型表、主表、明细表串起来的两条主链路。

第一条链路是“供应商—商品—采购入库”。一个供应商可以供应多种商品,所以供应商和商品是1 : N;一次采购入库会包含多件商品,所以采购入库主表和采购入库详情表又是1 : N。第二条链路是“客户—商品预定”,客户可以预定商品,预定记录要关联客户和商品,同时需要状态字段记录预定是否被处理。

类型表的作用是减少脏数据。供应商类型、供应商信用等级、商品类型、公告类型都单独建表,本质上是用外键关联替代字符串。这样做的好处是,商品类型名称改一次,所有商品同步生效,不用写多条 UPDATE。对答辩来说,这也是一个值得强调的“数据一致性”设计点。

下面列出这个项目中常见核心表的职责划分:

表名职责关联关系
sys_user员工/管理员登录账号关联角色或直接存角色字段
supplier_type供应商类型字典被供应商表引用
supplier_credit_level供应商信用等级字典被供应商表引用
supplier供应商基础信息关联类型表和信用等级表
product_type商品分类字典被商品表引用
product商品信息及库存数量关联供应商、商品类型
purchase_order采购入库主表关联供应商、操作员工
purchase_order_item采购入库明细表关联主表和商品
customer客户信息被商品预定表引用
product_reservation商品预定记录关联客户、商品
notice公告信息关联发布员工

不要把productpurchase_order_item混着放。商品表存的是当前库存快照和基础信息,而入库明细表存的是每一笔入库的历史行为。如果把历史明细塞进商品表,那么每次入库都要修改商品记录的商品数量,还无法追溯是哪笔单子带来的变化。

2.2 采购入库主从表:一次入库两条记录的关系

采购入库是该系统里最值得讲透的业务。一次入库动作会产生两条记录:一条在purchase_order,另一条在purchase_order_item。主表记录“这次入库的总信息”,比如入库单号、供应商 ID、入库时间、操作员 ID;明细表记录“这次入库都买了什么、各买多少、单价多少”。

这里的主从关系必须用外键关联起来。purchase_order_item里有一个order_id字段,指向purchase_order的主键id。这个字段既是外键,也是将来按单号查询入库详情的入口。

为什么不能只建一张表?假设一次采购入库包含 3 种商品,如果写在一张表里,那么同一张单据的供应商信息就会被重复存储 3 次。不仅冗余,而且当供应商名称需要修改时,要把历史单据里所有重复记录都找到改一遍。拆成主从表后,供应商信息只存在于主表,明细表只关心商品、数量和价格。

从批处理脚本文件名也能看出,这个项目是为本地运行准备的。真正用 MySQL 建表时,主从表的引擎建议统一为 InnoDB,外键约束可以不加,但事务必须支持。因为在后面代码里,主表和明细表的写入需要被同一个事务包住,InnoDB 的@Transactional才能生效。

2.3 用 SQL 定义库存关键表及字段约束

以下是库存场景下比较有代表性的建表语句,字段命名和资源源码里的实体类通常是对应关系。实际导入项目自带的 SQL 文件时,可能会多出一些数据字典表,但核心结构基本一致。

CREATE TABLE `supplier` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `supplier_name` VARCHAR(128) NOT NULL COMMENT '供应商名称', `type_id` BIGINT DEFAULT NULL COMMENT '供应商类型ID', `credit_level_id` BIGINT DEFAULT NULL COMMENT '信用等级ID', `contact_person` VARCHAR(64) DEFAULT NULL COMMENT '联系人', `contact_phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商表'; CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `product_name` VARCHAR(128) NOT NULL COMMENT '商品名称', `product_type_id` BIGINT DEFAULT NULL COMMENT '商品类型ID', `supplier_id` BIGINT DEFAULT NULL COMMENT '主要供应商ID', `unit` VARCHAR(16) DEFAULT NULL COMMENT '计量单位', `stock` INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `status` TINYINT DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE `purchase_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '入库单号', `supplier_id` BIGINT NOT NULL COMMENT '供应商ID', `total_quantity` INT DEFAULT 0 COMMENT '本次入库总数量', `total_amount` DECIMAL(10,2) DEFAULT 0 COMMENT '本次入库总金额', `operator_id` BIGINT DEFAULT NULL COMMENT '操作员工ID', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购入库主表'; CREATE TABLE `purchase_order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL COMMENT '采购入库主表ID', `product_id` BIGINT NOT NULL COMMENT '商品ID', `quantity` INT NOT NULL COMMENT '入库数量', `price` DECIMAL(10,2) NOT NULL COMMENT '入库单价', `amount` DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购入库明细表';

字段类型选择上,库存数量用INT,金额用DECIMAL(10,2),不建议用FLOAT存金额,二进制浮点数的精度问题会在累计对账时暴露。purchase_order_item里的amount字段记录的是当前单价乘以数量的小计,作为冗余字段,查询历史单据时不需要再动态计算。product表里的version字段是后面做并发扣减时用的,如果你拿到的源码没有这个字段,可以先加上,它在第 5 章会派上用场。

索引设计里,主键索引自然有,唯一索引用在order_no上,防止重复单号。purchase_order_itemidx_order_id是为了按单据查询明细时不走全表扫描。product.supplier_idproduct_type_id这种高频过滤条件,在实际项目里可以再加联合索引,但在数据量小的毕设系统中并不是必需项。

3. Spring Boot 分层实现:查询、入库与事务边界

数据模型定下来以后,后端代码就是围绕增删改查搭骨架。Spring Boot 项目里最常用的分层是 Controller、Service、Mapper,实体类单独放一层。理解这种分层的关键在于:Controller 只处理请求参数和响应格式,Service 处理业务逻辑和事务,Mapper 只操作数据库。

3.1 后端工程结构与 Maven 依赖选择

这个项目的后端源码遵循的是经典分包方式。controller包存放 REST 接口,service包存放业务实现,mapper包继承 BaseMapper(如果用的是 MyBatis-Plus),entity包放数据表实体类,config包放跨域、异常拦截等配置。

如果是 MyBatis-Plus 版本,pom.xml里核心依赖通常是这样:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>

选择 MyBatis-Plus 而非原生 MyBatis,是很多毕设项目的通行做法。MyBatis-Plus 自带BaseMapper,提供了selectByIdselectPage这些现成方法,单表 CRUD 不需要手写 XML。用到多表查询时,再用@Select注解或者 XML 文件补 SQL。这样既减少了重复代码量,也让答辩时讲得清:常规操作走 MyBatis-Plus,复杂查询走自写 SQL。

3.2 采购入库的双表写入为什么必须加 @Transactional

采购入库接口是整个系统里最容易被问倒的功能。因为一次请求要同时更新purchase_orderpurchase_order_item以及product.stock三处数据,任何一个环节失败,前面写入的数据都必须回滚。

一个可用的事务方法写法如下:

@Service public class PurchaseOrderService { @Autowired private PurchaseOrderMapper purchaseOrderMapper; @Autowired private PurchaseOrderItemMapper purchaseOrderItemMapper; @Autowired private ProductMapper productMapper; @Transactional(rollbackFor = Exception.class) public Long createPurchaseOrder(PurchaseOrderDTO dto) { // 1. 计算总数量和总金额,生成单据号 PurchaseOrder order = new PurchaseOrder(); order.setOrderNo(generateOrderNo()); order.setSupplierId(dto.getSupplierId()); order.setTotalQuantity(dto.getItems().stream() .mapToInt(PurchaseOrderItemDTO::getQuantity).sum()); order.setTotalAmount(dto.getItems().stream() .map(item -> item.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add)); // 2. 插入主表,回填主键 ID purchaseOrderMapper.insert(order); // 3. 循环插入明细表,同时扣减库存 for (PurchaseOrderItemDTO item : dto.getItems()) { PurchaseOrderItem orderItem = new PurchaseOrderItem(); orderItem.setOrderId(order.getId()); orderItem.setProductId(item.getProductId()); orderItem.setQuantity(item.getQuantity()); orderItem.setPrice(item.getPrice()); orderItem.setAmount(item.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); purchaseOrderItemMapper.insert(orderItem); // 扣减库存(这里用更新语句直接累减) productMapper.decreaseStock(item.getProductId(), item.getQuantity()); } return order.getId(); } }

这段代码里,@Transactional(rollbackFor = Exception.class)是关键参数。rollbackFor指明遇到任何异常都回滚,而不只是默认的运行时异常。如果漏掉这个参数,业务方法里抛出的受检异常不会触发事务回滚,很可能出现主表有记录、明细表没记录的情况。

generateOrderNo()是工具方法,常见做法是组合日期和随机数或雪花 ID,例如PO2025060715300001。演示时生成的单号要肉眼可读,不要直接用 UUID 的一长串,那样在列表页看不到规律。

productMapper.decreaseStock对应的 SQL 建议写成原子操作:

UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}

使用stock = stock - #{quantity}而不是stock = #{oldStock} - #{quantity},是为了避免先查再改造成的并发问题。stock >= #{quantity}条件保证扣减后不会出现负数。这是最简单也有效的库存防负校验,比先 SELECT 再判断更稳妥。

3.3 商品分页查询的 Controller 与前端参数约定

商品模块在管理系统中是高频操作,列表页需要支持分页、按商品名搜索。前端是 Vue 编译后的静态页面,接口返回结构必须统一,方便调用方解析。

Controller 接口示例:

@RestController @RequestMapping("/api/product") public class ProductController { @Autowired private ProductService productService; @GetMapping("/page") public Result<IPage<ProductVO>> page( @RequestParam(defaultValue = "1") long pageNum, @RequestParam(defaultValue = "10") long pageSize, @RequestParam(required = false) String keyword) { Page<Product> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like(Product::getProductName, keyword); } wrapper.orderByDesc(Product::getId); IPage<Product> result = productService.page(page, wrapper); return Result.success(convertToVO(result)); } }

这里的pageNumpageSize是前端列表页的常规参数名。keyword不是必传项,所以用required = falseLambdaQueryWrapper是 MyBatis-Plus 的条件构造器,用like做模糊查询,orderByDesc按 ID 倒序让最新商品排前面。

前后端参数对应关系可以参考下表:

前端参数后端参数类型说明
pageNumpageNumlong页码,从 1 开始
pageSizepageSizelong每页条数
keywordkeywordString商品名称模糊搜索关键字
ididLong编辑或删除时传的标识

分页返回的IPage对象里包含了totalrecordscurrentsize四个关键字段。Vue 前端的 el-table 和 el-pagination 组件会直接消费这些字段。如果你尝试把 IPage 直接序列化给前端,结果里会带有ordersoptimizeCountSql这类无意义属性。更好的做法是统一包一层 Result 对象,里面放codemessagedata,前端用response.data.code判断请求是否成功。

4. 前端 Vue 产物集成与一键启动脚本拆解

这个项目交付里没有src前端源码目录,而是直接提供了index.htmlcssjs编译产物,还有1-install.bat2-run.bat3-build.bat三个脚本。这意味着前端已经构建完成,Spring Boot 后端会把它作为静态资源托管。

4.1 编译后的静态资源如何被 Spring Boot 托管

Vue 项目执行npm run build后,dist目录下会生成index.htmlstaticassets子目录里的 CSS/JS 文件。把这些文件复制到 Spring Boot 的src/main/resources/static目录下,启动应用后直接访问http://localhost:8080/就能看到页面。

你可以注意到交付列表里的文件名带哈希后缀,例如app.a61e85bf.csschunk-vendors.a48a7cc1.css。这是 webpack 构建时的文件指纹机制。当文件内容变化时,文件名哈希同步变化,浏览器就知道需要重新拉新文件而不是用缓存。这个设计对毕设项目来说没有太大存在感,但在讲“前端构建产物如何与后端部署”这个问题时,能解释这个机制反而显得你有工程经验。

Spring Boot 的默认静态资源路径包括classpath:/static/classpath:/public/。把编译后的 Vue 文件放进resources/static,不需要额外写拦截器。但要注意,Vue Router 如果使用了 history 模式,刷新/product这类路径时会出现 404,因为 Spring Boot 在服务端找不到对应的 Controller。毕设项目一般用 hash 模式(URL 里有#),所以不太会踩这个坑。如果你发现刷新后白屏,先看 URL 里是#/product还是/product

4.2 install、build、run 三个批处理脚本的职责

三个脚本的名字带有强烈的人工操作顺序:先安装依赖、再运行、3 号脚本可以覆盖前两步。常见实现如下:

REM 1-install.bat @echo off cd /d %~dp0 call mvn clean install -DskipTests pause
REM 2-run.bat @echo off cd /d %~dp0 java -jar target/inventory-system-0.0.1-SNAPSHOT.jar pause
REM 3-build.bat @echo off cd /d %~dp0 call mvn clean package -DskipTests java -jar target/inventory-system-0.0.1-SNAPSHOT.jar pause

cd /d %~dp0是批处理脚本里最关键的写法。%~dp0表示当前脚本所在目录,加上/d后可以切换到那个盘符和路径。如果不写这一行,直接在资源管理器里双击 bat,工作目录可能停留在某个系统目录,Maven 会找不到pom.xml

mvn clean install -DskipTests是跳过单元测试并打包。跳过的原因很现实:项目自带测试类里如果写了依赖本地数据库的用例,直接跑mvn test会报数据库连接失败。毕设项目通常不需要跑单测,跳过反而更稳。

下面用表格整理脚本与日常启动的对应关系:

脚本实际作用适用场景
1-install.bat执行 Maven install 到本地仓库首次运行前准备 jar 包
2-run.bat直接运行 target 下的 jar打包完成后快速启动
3-build.batclean 后重新打包并启动修改代码后需要重新生效

4.3 application.yml、MySQL 初始化与运行参数

Spring Boot 的数据库配置写在application.yml里,启动时如果连不上 MySQL,应用会直接报错。典型配置如下:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/inventory_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto

characterEncoding=utf8是中文显示正常的前提。如果打开页面发现商品名乱码,第一检查这个参数。serverTimezone=Asia/Shanghai是为了避免 MySQL 8 默认的时区与 Docker 或本机不一致导致的数据库访问异常。map-underscore-to-camel-case: true会自动把数据库字段create_time映射为实体的createTime,不用逐个写@TableField注解。

数据库初始化通常是手动导入 MySQL,而不是让程序自动建表。先在 Navicat 或命令行里执行CREATE DATABASE inventory_db DEFAULT CHARSET utf8mb4;,然后导入项目附带的.sql文件。不要直接跑一个带CREATE DATABASE的脚本而不先确认字符集,因为建库语句里的字符集可能与 application.yml 里的连接地址不一致。

4.4 端口冲突和数据库连接失败的排错

启动时报Port 8080 was already in use,说明本机已有进程占用了 8080 端口。在命令行用netstat -ano | findstr 8080找到 PID,然后taskkill /PID <进程号> /F结束进程。如果不想杀进程,也可以把server.port改成 8081,同时 Vue 前端里所有 axios 请求的 baseURL 也要同步改。

数据库连接失败时,先区分是网络问题还是账号问题。本地安装的 MySQL 可能没有开服务,Windows 下检查服务列表里 MySQL 是否处于“运行”状态。如果项目用的是 MySQL 8,驱动必须是com.mysql.cj.jdbc.Driver,旧的com.mysql.jdbc.Driver虽然是兼容写法,但会有过时警告。

前端页面能打开、请求接口报 404 时,多半是请求路径不对。用浏览器 F12 看 Network,比对实际请求的 URL 和 Controller 上的@RequestMapping前缀。前后端的路径前缀不一致是这种打包部署模式里最常见的联调问题,修改前端源码后重新npm run build虽然麻烦,但确实是解决路径问题的正经途径。

5. 库存扣减的并发改造:从演示代码到答辩加分项

基础功能跑通后,库存管理系统的常见缺点是:扣减库存时没有任何并发控制。两个用户同时买同一件商品,代码就变成“判断库存大于 0 → 扣减”,结果最后实际扣减数量超过库存总量。在毕业设计中,只要能指出这个问题并给出解决方案,就比大多数按部就班写完 CRUD 的同学更有深度。

5.1 单机事务一边扣库存一边插入库明细的问题

第 3 章代码里的decreaseStock即使写在事务里,也只是防住了“库存变成负数”,并没有防住“超卖”。线程 A 和线程 B 同时读到库存剩余 5 件,A 扣减 3 件,B 扣减 3 件,数据库最终可能只剩 2 件或者直接违反stock >= quantity条件回滚。回滚虽然有提示,但用户体验很差,而且在高并发下事务冲突会导致大量重试。

5.2 用版本号实现乐观锁,给出 product 表改造方案

product表加一个version字段,每次扣减库存时同时更新version。执行 UPDATE 时带上旧的version条件,如果版本号不匹配,说明数据已被其他请求修改,本次更新影响行数为 0,业务层就能感知到冲突。

Mapper 方法定义示例:

@Update("UPDATE product SET stock = stock - #{num}, version = version + 1 " + "WHERE id = #{id} AND version = #{version} AND stock >= #{num}") int decreaseStockByIdAndVersion(@Param("id") Long id, @Param("num") Integer num, @Param("version") Integer version);

Service 层调用时的逻辑是:先查商品得到当前version,再执行更新。如果影响行数为 0,说明扣减失败或版本冲突,抛出异常让事务回滚。

Product product = productMapper.selectById(productId); int rows = productMapper.decreaseStockByIdAndVersion( productId, quantity, product.getVersion()); if (rows == 0) { throw new BizException("库存不足或数据已更新,请刷新后重试"); }

这里的version不是时间戳,也不是随机数,它只是在每次更新时加 1。天然解决了 ABA 问题:即使商品库存值被改了多次又改回来,版本号也一定大于旧值。

答辩演示时,可以在PurchaseOrderService.createPurchaseOrder方法里故意把Thread.sleep(200)加在读取 version 之后、执行更新之前,然后开两个浏览器标签页同时提交同一商品的两笔入库或出库单。观察第二次提交的后端日志,会发现明明商品库存足够,但更新行数为 0,业务层给出了“请刷新后重试”的提示。这就把“乐观锁控制库存超卖”从概念变成了可验证的实验过程。在演示记录里截两张图:一张是库存扣减前的明细,一张是扣减后的版本号变化,配合这个提示信息,比任何流程图都更能说明问题。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询