Spring Boot进销存系统:库存事务一致性与MyBatis实战
2026/9/10 5:24:55 网站建设 项目流程

简介:这是一套面向Java初学者与Spring Boot入门开发者的超市进销存系统实战项目,聚焦企业级业务场景中的商品管理、库存流转与员工协同等核心需求,助力掌握Web应用从需求分析到功能落地的完整开发流程。资源包含完整可运行源码、配套说明文档(含可行性分析、需求与总体设计、数据库建模、各模块实现细节及系统测试),内容覆盖登录注册、管理员后台、员工操作三大功能体系,结构清晰、注释规范。压缩包为RAR格式,共17.03MB,内含Java源文件、SQL建表脚本、Markdown或Word格式文档等典型开发资产,便于快速导入IDE调试与二次开发。目前已有115人学习下载,适合课程设计、毕业设计参考或Spring Boot项目练手,尤其利于理解MVC分层架构、RESTful接口设计及MySQL在进销存场景下的实际建模方法。

1. 这不是又一个 CRUD 演示项目:Spring Boot 超市进销存系统源码里藏着真实业务建模逻辑

你打开过多少个标着“Spring Boot 进销存”的 GitHub 仓库?点开后发现只有UserProductOrder三张表,连库存扣减都用UPDATE product SET stock = stock - 1 WHERE id = ?硬写,高并发下直接超卖。而这份「超市进销存系统」源码不同——它在pom.xml里明确依赖spring-boot-starter-jdbc而非 JPA,数据库设计文档第 13 页给出inventory_log表结构,字段含log_type ENUM('IN', 'OUT', 'ADJUST')before_stockafter_stockoperator_id,配合@Transactional(isolation = Isolation.SERIALIZABLE)注解落在InventoryService.adjustStock()方法上。这意味着它默认按「事务隔离 + 操作留痕」处理库存变更,不是教学玩具,是能跑在小型社区超市收银后台的真实骨架。适合两类人:刚学完 Spring Boot 基础想落地练手的开发者,以及需要快速搭建轻量级内部管理系统的 IT 运维或小店老板。它不追求微服务拆分,但把「采购入库→销售出库→盘亏盘盈→报表统计」这条主链路的事务边界、状态流转、数据一致性校验全写进了代码和文档。

2. 从 ER 图到 MyBatis 动态 SQL:数据库设计如何支撑进销存核心状态机

2.1 为什么不用 JPA 而选 MyBatis + 手动建表?

项目文档第 12 页的「数据库概念结构设计」明确画出 7 张实体表与 5 类关系,其中purchase_order(采购单)与purchase_item(采购明细)为一对多,sale_order(销售单)与sale_item(销售明细)同样一对多,但关键在于inventory_log(库存流水)表被设计为独立聚合根,不直接外键关联订单表,而是通过ref_id字段存储来源单据 ID,并用ref_type标识来源类型(PURCHASE/SALE/ADJUST)。这种设计规避了 JPA 的 N+1 查询陷阱,也避免因订单表结构变更导致库存流水查询失效。MyBatis 的优势在此凸显:可精准控制每条 SQL 的执行计划。例如统计某商品 30 天内出入库总量,JPA 需加载全部明细再 Java 层聚合,而本项目InventoryMapper.xml中的<select id="sumByGoodsIdAndDateRange">直接使用SUM(CASE WHEN log_type='IN' THEN quantity ELSE 0 END)在数据库层完成条件聚合,实测 10 万条流水下响应时间 < 80ms。

提示:项目未启用mybatis-plus,所有 Mapper 接口均继承自org.apache.ibatis.annotations.Mapper,SQL 全部写在 XML 文件中。这是刻意为之——便于审查每条 SQL 是否加了WHERE tenant_id = #{tenantId}(虽本项目无租户,但结构预留),也方便 DBA 优化执行计划。

2.2 关键表结构与 MyBatis 映射细节

inventory_log表结构(摘录自文档第 13 页):

字段名类型说明
idBIGINT PK主键
goods_idBIGINT NOT NULL商品ID
log_typeVARCHAR(10) NOT NULLIN/OUT/ADJUST
quantityDECIMAL(10,2) NOT NULL变更数量(正数为入,负数为出)
before_stockDECIMAL(10,2) NOT NULL变更前库存
after_stockDECIMAL(10,2) NOT NULL变更后库存
ref_idBIGINT来源单据ID(采购单/销售单ID)
ref_typeVARCHAR(20)PURCHASE/SALE/ADJUST
operator_idBIGINT NOT NULL操作人ID
created_atDATETIME NOT NULL创建时间

对应InventoryLog.java实体类中关键字段声明:

public class InventoryLog { private Long id; private Long goodsId; private String logType; // "IN", "OUT", "ADJUST" private BigDecimal quantity; private BigDecimal beforeStock; private BigDecimal afterStock; private Long refId; private String refType; // "PURCHASE", "SALE", "ADJUST" private Long operatorId; private LocalDateTime createdAt; // getter/setter 省略 }

InventoryMapper.xml中库存扣减核心逻辑(带防超卖校验):

<!-- InventoryMapper.xml --> <update id="decreaseStock" parameterType="map"> UPDATE inventory_log SET before_stock = ( SELECT stock FROM goods WHERE id = #{goodsId} ), after_stock = ( SELECT stock FROM goods WHERE id = #{goodsId} ) - #{quantity}, quantity = -#{quantity}, ref_id = #{refId}, ref_type = 'SALE', operator_id = #{operatorId}, created_at = NOW() WHERE goods_id = #{goodsId} AND ( SELECT stock FROM goods WHERE id = #{goodsId} ) >= #{quantity} <!-- 关键:WHERE 子句中嵌套子查询校验库存充足 --> </update>
2.2.1 为什么用子查询校验而非先查后更新?

若采用「SELECT stock FROM goods... → 判断 → UPDATE goods SET stock=stock-?」两步操作,在并发场景下必然出现超卖。本项目在UPDATE语句的WHERE条件中直接嵌套子查询(SELECT stock FROM goods...) >= #{quantity},使整个更新操作具备原子性:仅当当前库存满足条件时才执行更新,否则影响行为 0 行。后续业务代码需检查update返回值是否为 1,若为 0 则抛出InsufficientStockException。这是比乐观锁更轻量、比数据库行锁更可控的方案。

2.3 登录认证模块的权限分级实现

文档第 21 页「4.1 登录注册」指出系统含三类角色:超级管理员(可操作全部模块)、普通管理员(可管理商品/供应商/员工,不可修改系统参数)、员工(仅可处理销售单)。权限控制未使用 Spring Security 的复杂配置,而是基于@PreAuthorize注解与自定义PermissionEvaluator实现:

// CustomPermissionEvaluator.java @Component public class CustomPermissionEvaluator implements PermissionEvaluator { @Override public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) { if (!(auth.getPrincipal() instanceof UserDetails)) return false; UserDetails userDetails = (UserDetails) auth.getPrincipal(); String username = userDetails.getUsername(); // 从数据库查用户角色及权限码 List<String> perms = userPermissionService.findPermissionsByUsername(username); return perms.contains(permission.toString()); } }

对应 Controller 方法:

@RestController @RequestMapping("/api/admin") public class AdminController { @GetMapping("/goods") @PreAuthorize("hasPermission('admin','goods:read')") public Result<List<Goods>> listGoods() { ... } @PostMapping("/goods") @PreAuthorize("hasPermission('admin','goods:write')") public Result<Void> addGoods(@RequestBody Goods goods) { ... } }

user_permission表结构(文档第 13 页)含usernamepermission_code(如goods:read)、role_name字段,支持细粒度权限分配。这种方式比硬编码@PreAuthorize("hasRole('ADMIN')")更灵活,且权限数据可动态维护。

3. 采购入库与销售出库的事务边界设计:如何保证单据与库存强一致

3.1 采购入库流程:从单据创建到库存增加的完整链路

采购入库需完成三个动作:1)保存采购单头信息;2)保存采购明细;3)更新对应商品库存。若用默认@Transactional,一旦明细插入失败,单据头将回滚,但若库存更新失败,明细已落库,状态不一致。本项目在PurchaseService.createPurchaseOrder()方法中显式控制事务:

@Service public class PurchaseService { @Transactional(rollbackFor = Exception.class) public Result<Void> createPurchaseOrder(PurchaseOrder order) { // 步骤1:保存采购单头 purchaseOrderMapper.insert(order); // 步骤2:批量保存采购明细 List<PurchaseItem> items = order.getItems(); purchaseItemMapper.batchInsert(items); // 步骤3:逐条更新库存(关键:此处必须成功,否则整体回滚) for (PurchaseItem item : items) { // 调用库存服务,执行入库日志写入 inventoryService.increaseStock(item.getGoodsId(), item.getQuantity(), order.getId(), "PURCHASE", order.getOperatorId()); } return Result.success(); } }

InventoryService.increaseStock()内部调用前述InventoryMapper.decreaseStock(注意:方法名decreaseStock是历史遗留,实际根据quantity正负号处理增减,详见 XML 中quantity = #{quantity}的传参逻辑)。

3.1.1 库存更新失败时的补偿机制

increaseStock抛出异常(如数据库连接中断),整个createPurchaseOrder事务回滚,采购单头、明细、库存日志全部撤销。但若系统需更高可用性,文档第 31 页「5. 系统测试」提到「模拟网络抖动场景下,采购单创建成功但库存更新超时」,此时需人工介入核查purchase_order.status = 'PENDING_STOCK'的单据,并重试库存更新。项目未内置消息队列,故补偿逻辑需运维脚本或后台任务触发。

3.2 销售出库流程:防超卖与状态机驱动

销售出库流程更复杂,涉及客户信息、收银员、支付方式等。核心在于SaleService.createSaleOrder()中对库存的校验与扣减:

@Transactional(rollbackFor = Exception.class) public Result<Void> createSaleOrder(SaleOrder order) { // 1. 校验客户是否存在(调用 CustomerService) customerService.findById(order.getCustomerId()); // 2. 校验每件商品库存(关键:批量校验,非逐条) List<Long> goodsIds = order.getItems().stream() .map(SaleItem::getGoodsId).collect(Collectors.toList()); Map<Long, BigDecimal> stockMap = inventoryService.getStocksByIds(goodsIds); for (SaleItem item : order.getItems()) { BigDecimal available = stockMap.getOrDefault(item.getGoodsId(), BigDecimal.ZERO); if (available.compareTo(item.getQuantity()) < 0) { throw new InsufficientStockException( "商品ID " + item.getGoodsId() + " 库存不足,需" + item.getQuantity() + ",当前" + available); } } // 3. 保存销售单头与明细 saleOrderMapper.insert(order); saleItemMapper.batchInsert(order.getItems()); // 4. 批量扣减库存(原子性保障) for (SaleItem item : order.getItems()) { inventoryService.decreaseStock(item.getGoodsId(), item.getQuantity(), order.getId(), "SALE", order.getOperatorId()); } return Result.success(); }

InventoryService.getStocksByIds()对应 SQL 使用CASE WHEN聚合,一次查询返回全部商品当前库存,避免 N+1 查询。

3.3 数据库层面的约束强化

除应用层校验外,goods表添加了CHECK (stock >= 0)约束(文档第 13 页),确保即使绕过应用直接操作数据库,库存也不会为负。MySQL 8.0+ 支持该语法,建表语句片段如下:

CREATE TABLE `goods` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `stock` decimal(10,2) NOT NULL DEFAULT '0.00', PRIMARY KEY (`id`), CONSTRAINT `chk_stock_non_negative` CHECK (`stock` >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意:CHECK约束在 MySQL 5.7 及以下版本不生效,项目文档要求 MySQL 版本 ≥ 8.0.13。若部署环境为旧版 MySQL,需在应用层严格校验并移除该约束。

4. 启动与调试实战:从源码运行到定位库存扣减失败原因

4.1 环境准备与快速启动

项目基于 Spring Boot 2.7.x(pom.xml<spring-boot.version>2.7.18</spring-boot.version>),要求 JDK 8u202+ 或 JDK 11。数据库使用 MySQL 8.0+,需提前创建数据库supermarket并执行src/main/resources/sql/schema.sql(含建表语句)与data.sql(含初始化数据)。

启动步骤:

# 1. 修改 application.yml 中数据库配置 vim src/main/resources/application.yml # 修改 spring.datasource.url, username, password # 2. 导入 SQL 初始化数据(确保 MySQL 已启动) mysql -u root -p supermarket < src/main/resources/sql/schema.sql mysql -u root -p supermarket < src/main/resources/sql/data.sql # 3. 使用 Maven 启动(跳过测试) mvn clean spring-boot:run -DskipTests # 4. 访问 http://localhost:8080/login 查看登录页

application.yml关键配置说明:

spring: datasource: url: jdbc:mysql://localhost:3306/supermarket?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver sql: init: mode: always # 每次启动执行 schema.sql 和 data.sql(仅开发环境) schema-locations: classpath:sql/schema.sql >-- 查询某商品(ID=1001)的最新库存日志 SELECT * FROM inventory_log WHERE goods_id = 1001 ORDER BY created_at DESC LIMIT 5; -- 查询 goods 表当前库存 SELECT id, name, stock FROM goods WHERE id = 1001;

inventory_log最新记录的after_stockgoods.stock不符,说明存在未同步的库存变更。常见原因:

  • 手动执行了UPDATE goods SET stock = ?绕过日志表;
  • inventory_log表有脏数据(如log_type='IN'quantity为负);
  • goods.stock字段被其他未审计的程序修改。

修复脚本(以商品 ID 1001 为例):

-- 1. 重置 goods.stock 为日志表最终值 UPDATE goods g JOIN ( SELECT goods_id, after_stock FROM inventory_log WHERE goods_id = 1001 ORDER BY created_at DESC LIMIT 1 ) l ON g.id = l.goods_id SET g.stock = l.after_stock WHERE g.id = 1001; -- 2. 验证一致性 SELECT g.id, g.name, g.stock, l.after_stock FROM goods g LEFT JOIN ( SELECT goods_id, after_stock FROM inventory_log WHERE goods_id = 1001 ORDER BY created_at DESC LIMIT 1 ) l ON g.id = l.goods_id WHERE g.id = 1001;

4.3 日志与监控配置要点

项目使用 Logback,logback-spring.xml中配置了inventory包的 DEBUG 级别日志,便于追踪库存变更:

<!-- logback-spring.xml --> <logger name="com.example.supermarket.service.inventory" level="DEBUG"/>

InventoryService.decreaseStock()执行时,会输出类似日志:

DEBUG c.e.s.s.i.InventoryService - 扣减商品[1001]库存[2.00],来源单据[SALE-20240520001],操作人[admin] DEBUG c.e.s.m.InventoryMapper - ==> Preparing: UPDATE inventory_log SET before_stock = (SELECT stock FROM goods WHERE id = ?), ...

若需监控库存变更频率,可在application.yml中启用 Actuator 的metrics端点:

management: endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: metrics: show-details: ALWAYS

访问http://localhost:8080/actuator/metrics/inventory.decrease.count即可查看扣减调用次数。

5. 进阶技巧:在不改代码前提下扩展「盘亏盘盈」功能

5.1 复用现有库存流水表实现盘亏盘盈

「盘亏盘盈」即实物盘点后调整账面库存,属于inventory_log.log_type = 'ADJUST'的典型场景。项目已预留该类型,但未提供前端入口。扩展步骤如下:

  1. 新增 Controller 接口AdjustmentController.java):
@RestController @RequestMapping("/api/adjustment") public class AdjustmentController { @PostMapping @PreAuthorize("hasPermission('admin','adjustment:write')") public Result<Void> createAdjustment(@RequestBody AdjustmentRequest request) { // request 包含 goodsId, adjustQuantity(正为盘盈,负为盘亏), reason inventoryService.adjustStock(request.getGoodsId(), request.getAdjustQuantity(), null, // ref_id 为空,因无来源单据 "ADJUST", SecurityUtils.getCurrentUserId()); return Result.success(); } }
  1. 复用InventoryService.adjustStock()方法(无需新增逻辑):
// InventoryService.java 中已有此方法 public void adjustStock(Long goodsId, BigDecimal quantity, Long refId, String refType, Long operatorId) { // 内部调用 inventoryMapper.insertAdjustLog(...) 插入 ADJUST 类型日志 // 并更新 goods.stock 字段 }
  1. 新增inventory_log插入 SQLInventoryMapper.xml):
<insert id="insertAdjustLog" parameterType="map"> INSERT INTO inventory_log ( goods_id, log_type, quantity, before_stock, after_stock, ref_id, ref_type, operator_id, created_at ) VALUES ( #{goodsId}, 'ADJUST', #{quantity}, (SELECT stock FROM goods WHERE id = #{goodsId}), (SELECT stock FROM goods WHERE id = #{goodsId}) + #{quantity}, #{refId}, #{refType}, #{operatorId}, NOW() ) </insert>

5.2 前端快速接入方案

若使用 Vue.js 前端,只需新增一个AdjustmentForm.vue组件,调用/api/adjustment接口。关键点在于adjustQuantity的输入校验:

// AdjustmentForm.vue 中的提交方法 submitForm() { const params = { goodsId: this.form.goodsId, adjustQuantity: this.form.adjustQuantity, reason: this.form.reason }; // 盘亏盘盈数量必须为非零小数 if (params.adjustQuantity === 0 || !/^-?\d+(\.\d{1,2})?$/.test(params.adjustQuantity.toString())) { this.$message.error('调整数量必须为非零数字,最多两位小数'); return; } api.adjustment.create(params).then(() => { this.$message.success('盘亏盘盈已提交'); this.$router.push('/inventory/log'); }); }

提示:adjustQuantity为正表示盘盈(账面少,实物多),为负表示盘亏(账面多,实物少)。此逻辑与inventory_log.quantity字段定义完全一致,无需额外转换。

5.3 盘点差异报表生成

利用现有inventory_log表,可快速生成盘点差异报表。SQL 示例(查询最近 7 天所有 ADJUST 类型日志):

SELECT g.name AS 商品名称, i.quantity AS 调整数量, i.before_stock AS 调整前库存, i.after_stock AS 调整后库存, i.created_at AS 调整时间, u.username AS 操作人, i.remark AS 原因 FROM inventory_log i JOIN goods g ON i.goods_id = g.id JOIN sys_user u ON i.operator_id = u.id WHERE i.log_type = 'ADJUST' AND i.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY i.created_at DESC;

将此 SQL 封装为AdjustmentReportService.generateRecentReport()方法,即可在后台管理界面提供「近7天盘点差异」导出功能。

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

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

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

立即咨询