Java仓库管理系统源码实战:Spring Boot库存事务与防超卖 简介这是一套面向高校课程设计、毕业设计及Java Web入门进阶者的仓库管理系统项目源码围绕库存管理、进出货流程、供应商与订单数据维护等场景展开可帮助读者理解一个中小型管理系统从业务建模到页面呈现的完整实现思路。压缩包共70个文件约8.52MB以16个Java源文件为核心配合43张界面截图与素材图、2个PSD设计稿、2份TXT安装说明及项目配置与数据库文件便于对照代码、界面和运行环境进行学习。目前已有4615人学习下载具备较好的参考基础。读者可从中梳理MVC分层、数据库访问、用户登录与权限控制、统计图表展示等常见模块的组织方式并结合安装说明完成环境配置与源码调试适合用于课程作业改造、项目复现和Java Web开发能力训练。1. 接手一个五六个货架的备件仓时最头疼的不是没有系统而是用 Excel 管了三年每个月盘点总会差几十件也没人说得清是哪一单出的问题。这套 Java 仓库管理系统项目源码就是针对这类场景准备的商品档案、仓库与库位、入库单、出库单、实时库存、库存流水、盘点调整、角色权限一条主线全串起来跑起来就是一个能录单、能查库存、能对账的后台。它适合三类人刚学完 Spring Boot、想找一个业务完整的项目来练手的初中级 Java 开发要给小厂或自家门店做一套内部工具的外包人员以及需要交课程设计的同学。接下来按「技术栈和目录怎么排 → 怎么在本机跑起来 → 入库出库的核心链路怎么写 → 踩过哪些坑 → 怎么往上加功能」的顺序拆解。2. 技术栈与目录结构Spring Boot 三层骨架是如何实现的先把定位说清楚这不是一个只做增删改查的 CRUD 项目它的复杂度主要在「单据状态流转 库存变更 流水留痕」这三件事的一致性上。所以看目录结构时重点不在包名有多整齐而在于业务逻辑有没有全部集中在 Service 层、库存变更有没有统一入口。我一般拿到这类源码第一件事就是找service/impl下跟Stock相关的实现类如果库存加减分散在三四个 Controller 里各写各的那后期基本不修改如果集中在一个StockService里这套代码就有继续修改的价值。2.1 技术选型与版本组合这套源码用的是典型的国内中小企业后台组合。整套技术栈里没有冷门依赖这一点很重要意味着你在自己机器上大概率不用为了某个怪异的 jar 包折腾半天。层次技术常见版本说明运行环境JDK1.8 / 17源码编译级别默认 1.8用 17 跑一般也没问题构建Maven3.6单模块或父子模块两种形态都有框架Spring Boot2.7.x集成了 Web、事务、参数校验持久层MyBatis-Plus3.5.x单表 CRUD 零 SQL复杂查询走 XML数据库MySQL8.0utf8mb4InnoDB缓存Redis5.0存登录 token 和字典缓存可选认证JWT 拦截器—无状态前后端分离好接前端Vue2 后台模板—独立工程npm run dev起 8081为什么用 MyBatis-Plus 而不是纯 MyBatis因为仓库系统里 80% 的查询是「按条件分页查列表」字段多、条件杂纯 XML 写一遍where 11的拼接非常耗时。MyBatis-Plus 的LambdaQueryWrapper能覆盖这部分剩下 20% 的入库聚合、库存报表再落到 XML 里手写。这种「八成靠封装、两成靠手写」的搭配是我在中小项目里最常用的做法比全 JPA 好控制 SQL比全手写省一半时间。至于 JWT 而不是 Session是为了让前端独立部署时不用处理 cookie 跨域。代价是 token 失效控制要靠 Redis 黑名单源码里对这块做得比较简logout只清了前端本地存储服务端 token 在过期前仍有效。这个取舍对小系统能接受后面讲二次开发时我会提怎么补。2.2 目录结构与模块划分后端整体是标准的 controller / service / mapper / entity 四层额外的common、config、interceptor放公共能力。wms/ ├── pom.xml ├── src/main/java/com/example/wms/ │ ├── WmsApplication.java # 启动类MapperScan 在这里 │ ├── common/ │ │ ├── R.java # 统一返回体 {code,msg,data} │ │ ├── BizException.java # 业务异常被全局处理器捕获 │ │ ├── GlobalExceptionHandler.java │ │ └── PageResult.java # 分页返回封装 │ ├── config/ │ │ ├── MybatisPlusConfig.java # 分页插件、自动填充 │ │ ├── WebMvcConfig.java # 拦截器注册、跨域 │ │ └── RedisConfig.java │ ├── interceptor/ │ │ └── AuthInterceptor.java # 解析 token塞 UserContext │ ├── controller/ │ │ ├── GoodsController.java │ │ ├── InboundController.java │ │ ├── OutboundController.java │ │ └── StockController.java │ ├── service/ │ │ ├── StockService.java # 库存变更的唯一入口 │ │ └── impl/... │ ├── mapper/ │ └── entity/ └── src/main/resources/ ├── application.yml ├── mapper/ # 与 mapper 接口同名的 XML └── sql/wms.sql # 建库建表 初始数据前端是独立目录wms-ui/src/api按模块拆请求src/views按业务拆页面src/utils/request.js里统一注入 token 和处理code ! 200。这个结构谈不上多先进但边界清楚要加一个「调拨单」后端加三件套Controller/Service/Mapper前端加一个 api 文件和一个 views 目录不需要动别的模块。有个细节值得注意实体类没有直接用数据库字段名而是TableField显式映射比如goods_name对应goodsName。这看着啰嗦但在多人协作时能避免「驼峰下划线映射到底开没开」这类低水平问题。2.3 数据库表设计七张表如何支撑整个系统表不多但主次很分明。核心是「库存表」和「流水表」两张其他都是围绕它们的主数据。-- 商品档案 create table wms_goods ( id bigint primary key auto_increment, goods_code varchar(32) not null comment 商品编码业务唯一, goods_name varchar(64) not null, spec varchar(64) comment 规格, unit varchar(16) comment 单位, category_id bigint, status tinyint default 1 comment 1启用 0停用, unique key uk_goods_code (goods_code) ) engineInnoDB default charsetutf8mb4; -- 实时库存一个仓库 一个商品 一行 create table wms_stock ( id bigint primary key auto_increment, warehouse_id bigint not null, goods_id bigint not null, qty int not null default 0 comment 可用数量, version int not null default 0 comment 乐观锁版本号, unique key uk_wh_goods (warehouse_id, goods_id) ) engineInnoDB default charsetutf8mb4; -- 库存流水只增不改对账的唯一依据 create table wms_stock_flow ( id bigint primary key auto_increment, warehouse_id bigint not null, goods_id bigint not null, change_qty int not null comment 正数入库负数出库, biz_type tinyint not null comment 1入库 2出库 3盘盈 4盘亏, biz_no varchar(32) comment 来源单据号, operator_id bigint, create_time datetime default current_timestamp, key idx_goods_time (goods_id, create_time) ) engineInnoDB default charsetutf8mb4;关键设计点有两个。第一库存没有用「出入库明细实时汇总」的方式算而是单独存一张wms_stock。汇总算虽然永远不会不一致但列表页一查就是几十万行 group by完全没法用。第二wms_stock_flow是只增不改的流水表任何时候库存对不上都能拿它把某商品某仓的历史变动一条条拉出来复盘。这就是我前面说的「黑匣子」出问题时有据可查比什么都重要。uk_wh_goods这个唯一索引是整个并发方案的基础后面入库用on duplicate key update、出库用带条件的update都依赖它。如果建表时漏了这个索引代码跑起来会多出一堆重复行而且库存数会翻倍。3. 环境搭建与启动从空库到登录首页的完整跑通步骤跑这类源码最怕的就是「IDE 打开一堆红叉不知道从哪下手」。我的习惯是先把依赖、数据库、配置三件事按顺序解决掉确认后端能起来再接前端。顺序反了就会出现「页面白屏但其实是后端 500」这种排查方向错误。3.1 本地环境与依赖清单先把这几个命令敲一遍版本对不上就别急着往下走。java -version # 期望 1.8 或 17 mvn -v # 期望 3.6 以上 node -v # 前端用 vue216.x 最稳 npm -v mysql --version # 8.0Redis 如果本机没装先看配置里有没有强依赖。多数版本的代码只在登录和字典缓存用到 Redis可以在application.yml里先注释掉相关配置再临时把缓存实现改成内存版能跑起来再补。我一般会先让它跑通再追求「依赖齐全」不然一上手就要装三四个中间件热情就消耗殆尽了。IDEA 导入后要确认两处pom.xml是否全部依赖下载完成看 Maven 面板有没有红色波浪线以及Project Structure里的 SDK 和 Language Level 是否一致。这两处不一致是「明明代码没问题却编译不过」的高发区。3.2 建库、导数据、改配置源码的resources/sql/wms.sql一般包含建库语句和初始数据直接整份导入最省事。# 建库如果 sql 文件里没有 mysql -uroot -p -e CREATE DATABASE wms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 导入结构和初始数据 mysql -uroot -p wms src/main/resources/sql/wms.sql # 验证能看到商品、库存、菜单这几张表就对了 mysql -uroot -p wms -e show tables;接着改application.yml重点是数据源和 Redis 两段。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/wms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码 redis: host: 127.0.0.1 port: 6379 database: 0 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期打开能看真实 SQLURL 里那串参数不是凑数的serverTimezoneAsia/Shanghai不写插入时间和查询时间会差 8 小时useSSLfalse不写MySQL 8 在某些环境下会报连接警告甚至握手失败allowPublicKeyRetrievaltrue是针对 8.0 默认认证插件的兼容开关。这几个我每次都手写一遍从来不指望记忆。log-impl那一行建议调试期打开控制台会把执行的 SQL 和参数原样打出来。这比在 Service 里到处System.out.println强得多也是后面排查「查不到数据」的第一手材料。3.3 后端启动与前端联调后端启动有两种方式命令行更快# 方式一命令行 mvn clean package -DskipTests java -jar target/wms-1.0.0.jar # 方式二IDE 里直接跑 WmsApplication # 看到 Started WmsApplication in x seconds 就算成功起来后先别开前端用 curl 打一下登录接口确认后端链路是通的。curl -X POST http://127.0.0.1:8080/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}返回体里应该有一个token字段。拿到 token 再请求一个需要鉴权的接口比如/api/stock/page把 token 放在Authorization头里。这一步能过说明数据库、拦截器、统一返回都正常再去调前端就不会抓瞎。前端部分cd wms-ui npm install --registryhttps://registry.npmmirror.com npm run dev # 默认起在 8081浏览器打开后账号密码用 sql 里初始化那个前端请求地址通常在.env.development或src/utils/request.js里配置baseURL。如果后端端口不是 8080改这里就行别去改代码里的硬编码。3.4 第一个可验证闭环新增商品 → 入库 → 查库存登录进去之后别急着点各个菜单按这个顺序走一遍能最快确认业务链路是否完整在「商品管理」新增一个商品编码随便填一个没重复的比如TEST001。在「仓库管理」确认至少有一个仓库没有就建一个。在「入库管理」新建入库单选刚建的商品和仓库数量填 10保存为草稿再点确认。回到「库存查询」按商品编码搜TEST001应该看到数量 10。打开「库存流水」能看到一条biz_type1、change_qty10的记录。这五步里任何一步断了问题范围都很明确第 3 步失败是单据或事务问题第 4 步查不到是库存更新没生效第 5 步没记录是流水没写。比漫无目的地翻代码高效得多。4. 核心链路拆解一次入库单提交背后的库存扣减与事务控制真正值得逐行阅读的代码就那么几个方法。入库和出库这两条链路几乎涵盖了这套源码所有的设计取舍状态机怎么控、事务边界划在哪、并发怎么挡、流水怎么留。看懂了这两条其他模块都是同一套模式在重复。4.1 Controller 层参数校验与统一返回Controller 的职责要尽可能单一只做三件事接参数、校验、调 Service。RestController RequestMapping(/api/inbound) public class InboundController { Resource private InboundService inboundService; /** * 确认入库 * param orderId 单据主键路径参数 * return 统一返回体成功时 data 为 null */ PostMapping(/confirm/{orderId}) public RVoid confirm(PathVariable Long orderId) { // 操作人从拦截器塞进 ThreadLocal 的上下文里取不从请求体传 Long operatorId UserContext.getUserId(); inboundService.confirm(orderId, operatorId); return R.ok(); } /** * 入库单分页查询 * param query 查询条件含 pageNum / pageSize / orderNo / status */ GetMapping(/page) public RPageResultInboundOrderVO page(InboundQuery query) { return R.ok(inboundService.page(query)); } }operatorId从UserContext取而不是让前端传这一点很重要。前端传的参数永远可以被篡改操作人属于审计字段必须由服务端根据 token 解析出来。源码里如果哪个接口的操作人是前端传的我一定会改掉。R这个统一返回体承担了「前端只认一种结构」的职责code200表示业务成功code500表示业务异常code401表示未登录。前端在request.js的响应拦截器里按 code 分流不用每个页面写一遍判断。参数校验我一般用Valid加注解而不是手写 if。但要注意Valid只对RequestBody生效PathVariable和 query 参数得在 Controller 上加Validated才起作用这个坑后面还会提。4.2 Service 层入库单落库与库存变更的事务边界这是整套代码的核心。三个动作必须在同一个事务里改单据状态、加库存、写流水。少一个都会出现「单子确认了但库存没加」这种最难查的不一致。Service public class InboundServiceImpl implements InboundService { Resource private InboundOrderMapper orderMapper; Resource private InboundItemMapper itemMapper; Resource private StockMapper stockMapper; Resource private StockFlowMapper flowMapper; /** * 确认入库 * param orderId 入库单主键 * param operatorId 当前操作人来自 token */ Override Transactional(rollbackFor Exception.class) public void confirm(Long orderId, Long operatorId) { InboundOrder order orderMapper.selectById(orderId); if (order null) { throw new BizException(入库单不存在); } // 状态机只允许 草稿(0) - 已确认(1) if (!Integer.valueOf(0).equals(order.getStatus())) { throw new BizException(当前单据状态不允许确认); } // 带原状态做条件更新等于用数据库行锁挡住并发重复点击 int rows orderMapper.updateStatus(orderId, 0, 1, operatorId); if (rows 0) { throw new BizException(单据已被处理请刷新后重试); } ListInboundItem items itemMapper.selectByOrderId(orderId); if (items.isEmpty()) { throw new BizException(入库单没有明细不能确认); } for (InboundItem item : items) { // 库存加不存在则插入存在则累加 stockMapper.upsertIncrease(item.getWarehouseId(), item.getGoodsId(), item.getQty()); // 每笔变动留痕 StockFlow flow new StockFlow(); flow.setWarehouseId(item.getWarehouseId()); flow.setGoodsId(item.getGoodsId()); flow.setChangeQty(item.getQty()); flow.setBizType(1); flow.setBizNo(order.getOrderNo()); flow.setOperatorId(operatorId); flowMapper.insert(flow); } } }几个参数和写法要解释清楚。rollbackFor Exception.class不能省。Spring 默认只在遇到RuntimeException和Error时回滚如果中途抛的是受检异常事务不会回滚就会出现「状态改了、库存没加」的半截数据。这是我见过频率最高的事务翻车点。updateStatus(orderId, 0, 1, operatorId)里传了「原状态 0」和「目标状态 1」对应 SQL 的where status 0。如果两个线程同时点确认只有一个能 update 成功拿到rows1另一个拿到 0 直接抛异常。这就是用数据库的原子性代替应用层加锁比synchronized可靠得多因为后者在多实例部署时完全失效。循环里逐条upsert在明细行数不多时没问题。如果一张单子有上千行明细我一般会改成批量插入流水 按商品聚合后再更新库存减少往返次数但聚合逻辑要保证同一商品多行明细的数量先加总否则on duplicate key update会在同一事务内反复命中同一行。4.3 Mapper 层乐观锁更新与流水写入对应的 XML 里两个 update 是整个并发方案落地的关键。!-- 条件更新只有原状态匹配才更新成功返回影响行数 -- update idupdateStatus update wms_inbound_order set status #{toStatus}, confirm_by #{operatorId}, confirm_time now() where id #{orderId} and status #{fromStatus} /update !-- 库存累加依赖 uk_wh_goods 唯一索引冲突时走 update 分支 -- update idupsertIncrease insert into wms_stock (warehouse_id, goods_id, qty, version) values (#{warehouseId}, #{goodsId}, #{qty}, 0) on duplicate key update qty qty #{qty}, version version 1 /updateon duplicate key update是 MySQL 的语法换成其他数据库就得用merge into或者先查后改。用它有个副作用要知道当插入冲突触发 update 时自增主键会被消耗掉主键会出现空洞。业务上无所谓但如果有人拿id做连续编号展示就会看到跳号。version字段在这里的用途更像是变更次数记录真正防并发靠的是qty qty #{qty}这条原子表达式。数据库在执行单条 update 时会加行锁两个请求进来是串行执行的读到的qty一定是上一个请求写完之后的值不存在丢失更新。4.4 出库与并发把库存扣减写成一条带条件的 update出库比入库麻烦因为要防超卖。很多源码在这里犯的错是「先 select 查库存判断够不够再 update 扣减」两个线程同时查到 10各自扣 8最后库存变成 -6。正确写法是把判断塞进 SQL!-- 扣减库存qty #{qty} 是防超卖的关键返回 0 行即库存不足 -- update iddecrease update wms_stock set qty qty - #{qty}, version version 1 where warehouse_id #{warehouseId} and goods_id #{goodsId} and qty #{qty} /updateService 里只需要看返回值int rows stockMapper.decrease(item.getWarehouseId(), item.getGoodsId(), item.getQty()); if (rows 0) { // 事务会整体回滚前面已扣的明细也会退回 throw new BizException(商品[ item.getGoodsName() ]库存不足); }where qty #{qty}这个条件让「判断」和「扣减」在一条 SQL 里原子完成。返回 0 行说明要么库存不够要么这行根本不存在仓库和商品组合没建过库存记录两种情况都该报错。这里抛异常后整个事务回滚同一张出库单里前面已经扣成功的其他商品会自动还原不用手写补偿逻辑。这也是为什么明细循环必须放在一个事务里的原因。4.5 分页与查询插件配不对 total 永远是 0列表页全靠 MyBatis-Plus 的分页插件配置只有几行但漏了就是「数据查出来了总条数显示 0翻页翻不动」。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor page new PaginationInnerInterceptor(DbType.MYSQL); // 单页上限防止前端传 pageSize999999 把数据库拖死 page.setMaxLimit(500L); // 请求页码超过总页数时返回空列表而不是回到第一页便于前端提示 page.setOverflow(false); interceptor.addInnerInterceptor(page); return interceptor; } }setMaxLimit这一行是我在线上项目里必加的。曾经有个页面被人手动把pageSize改成 20 万一次性把几万行数据序列化成 JSON接口直接超时还把数据库连接池占满了。加个上限几十行代码就能避免一次事故。查询条件本身用LambdaQueryWrapper拼注意like的位置商品名称这类模糊查询用like编码这种唯一键用eq更快因为能走索引。5. 常见问题排查源码跑不通时按这几条顺序查这套代码在我手里复现过不止一次翻车的点相对固定。下面五条基本覆盖了「下载下来跑不起来」的绝大多数情况按出现频率排序。5.1 启动报 Invalid bound statement (not found)现象项目能启动一调接口就报org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)日志里能看到某个 Mapper 方法名。原因Mapper 接口和 XML 没对上。要么mapper-locations路径写错了要么 XML 的namespace不是接口的全限定名要么 XML 放在了src/main/java下而 Maven 打包时没把它带进target/classes。解决先确认target/classes/mapper/目录下有没有对应的 XML 文件没有就是资源过滤没配。在pom.xml的build里加上资源目录配置把src/main/java下的**/*.xml也打进包里。再检查 XML 的namespace是否精确到包名加类名多一个空格都算错。5.2 时间差 8 小时、中文变问号现象新增一条记录数据库里存的时间比实际早 8 小时或者商品名称里的中文入库后变成???。原因两个独立问题凑一块了。时间是 JDBC URL 少了serverTimezone或者服务端时区不是东八区中文是数据库或表的字符集不是utf8mb4或者连接串没带characterEncodingutf8。解决URL 补齐serverTimezoneAsia/ShanghaicharacterEncodingutf8同时确认建库语句用的是DEFAULT CHARACTER SET utf8mb4。如果表已经建好且字符集错了alter table wms_goods convert to character set utf8mb4;能救回来但已有数据里的问号救不回来只能重录。这类问题我一般在新项目初始化阶段就写死不给它出现的机会。5.3 登录后所有接口 401或者浏览器报跨域现象登录接口能返回 token但后续请求全部 401或者控制台一片红提示被 CORS 策略拦截。原因401 多半是前端没把 token 放进请求头或者拦截器里取 header 的名字和前端写的不一致Authorizationvstoken。跨域则是后端没配CorsFilter或者配了但allowedOrigins写的是*同时allowCredentials为 true浏览器直接拒绝。解决先在浏览器 Network 面板看请求头里到底有没有 token没有就改request.js的请求拦截器。跨域建议在后端WebMvcConfig里显式列出前端地址别用*如果用了 Nginx 反向代理同源之后跨域问题自然消失这也是我更推荐的做法。5.4 库存出现负数现象压测或者两个人同时点出库库存表里出现-3这种值。原因扣减逻辑写成了「先查再改」判断和更新之间有窗口期或者用了update ... set qty #{newQty}直接把算好的绝对值写回去后写的覆盖先写的。解决统一改成update ... set qty qty - #{qty} where qty #{qty}用返回行数判断成败返回 0 就抛异常回滚。改完记得补一个并发测试用 JMeter 或 ab 起 20 个线程同时打同一个出库接口看最终库存是否等于「初始库存 - 成功单数 × 数量」对得上才算真的修好。5.5 分页 total 恒为 0或者翻到第二页数据重复现象列表能查出数据但总数显示 0分页组件只有一页或者第二页的内容和第一页一样。原因忘了注册分页插件或者 SQL 里自己写了limit又叠加了插件的分页导致参数被拼了两次。解决确认MybatisPlusConfig里的PaginationInnerInterceptor被 Spring 扫描到类上有Configuration且启动类包路径能覆盖到。然后检查自定义 XML 里有没有手写limit有就去掉交给插件处理。另外order by一定要有确定性字段只按create_time排序时同一秒的多条记录顺序不稳定翻页就会出现重复或漏数据加上id作为第二排序字段即可。6. 二次开发改造加一张盘点单把库存校准做成可复用能力仓库系统上线三个月后业务方一定会提一个需求「盘点」。它的本质是允许人为把库存改成一个指定值并留下原因。这套源码里没有盘点模块正好可以拿它练手也能验证你对前面那套库存链路的理解是否到位。改造思路是复用而不是新造。新建wms_check_order和wms_check_item两张表字段结构和入库单基本一致只是明细里多两个字段book_qty账面数和real_qty实盘数。确认盘点时不直接改库存而是算出差额real_qty - book_qty走已有的StockService变更入口流水类型记 3盘盈或 4盘亏。// 盘点确认的核心差额走统一的库存变更入口不另开一条写库路径 for (CheckItem item : items) { int diff item.getRealQty() - item.getBookQty(); if (diff 0) continue; // 没差异不用动库存 if (diff 0) { stockMapper.upsertIncrease(item.getWarehouseId(), item.getGoodsId(), diff); } else { int rows stockMapper.decrease(item.getWarehouseId(), item.getGoodsId(), -diff); if (rows 0) throw new BizException(账面库存已变化请重新盘点); } flowMapper.insert(StockFlow.ofCheck(item, diff, operatorId)); }这段代码的价值在于复用库存加减只有一套实现盘点、调拨、退货都走它后续加任何新单据类型都不用重写并发控制。我见过太多项目每加一种单据就复制一份库存扣减代码半年后六份实现各不相同改一个 bug 要改六遍。验证方法也很直接拿一个库存为 10 的商品建盘点单实盘填 8确认后库存应该是 8流水里多一条change_qty-2的记录再建一张实盘填 12 的库存变 12流水多一条2。两次都对了说明差额计算和流水方向没搞反。至于book_qty什么时候取建议在「新建盘点单」时就快照下来而不是确认时再查。因为盘点期间可能还有出入库确认时查到的账面数已经变了差额就对不上用户会觉得系统在乱改数。这个细节不写进代码里测试阶段很难发现等真实业务跑起来就是一笔糊涂账。从那以后我每次改库存相关的代码都强制走一遍「造并发 对流水」的流程先用脚本打 20 个并发请求再把流水按商品汇总跟库存表的数做一次比对两个数不等就说明有地方绕过了统一入口。这个习惯帮我拦下过好几次「临时加个接口直接 update 库存」的偷懒改动也让我对这套源码的信任度一直保持在一个可控的水平。希望帮到你。本文还有配套的精品资源点击获取