Spring Boot进销存系统:库存事务一致性与MyBatis实战
发布时间:2026/9/10 5:24:58 作者:尧图编辑部 阅读量:1,286

简介这是一套面向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 仓库点开后发现只有User、Product、Order三张表连库存扣减都用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_stock、after_stock、operator_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 的 N1 查询陷阱也避免因订单表结构变更导致库存流水查询失效。MyBatis 的优势在此凸显可精准控制每条 SQL 的执行计划。例如统计某商品 30 天内出入库总量JPA 需加载全部明细再 Java 层聚合而本项目InventoryMapper.xml中的select idsumByGoodsIdAndDateRange直接使用SUM(CASE WHEN log_typeIN THEN quantity ELSE 0 END)在数据库层完成条件聚合实测 10 万条流水下响应时间 80ms。提示项目未启用mybatis-plus所有 Mapper 接口均继承自org.apache.ibatis.annotations.MapperSQL 全部写在 XML 文件中。这是刻意为之——便于审查每条 SQL 是否加了WHERE tenant_id #{tenantId}虽本项目无租户但结构预留也方便 DBA 优化执行计划。2.2 关键表结构与 MyBatis 映射细节inventory_log表结构摘录自文档第 13 页字段名类型说明idBIGINT PK主键goods_idBIGINT NOT NULL商品IDlog_typeVARCHAR(10) NOT NULLIN/OUT/ADJUSTquantityDECIMAL(10,2) NOT NULL变更数量正数为入负数为出before_stockDECIMAL(10,2) NOT NULL变更前库存after_stockDECIMAL(10,2) NOT NULL变更后库存ref_idBIGINT来源单据ID采购单/销售单IDref_typeVARCHAR(20)PURCHASE/SALE/ADJUSToperator_idBIGINT NOT NULL操作人IDcreated_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 iddecreaseStock parameterTypemap 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 子句中嵌套子查询校验库存充足 -- /update2.2.1 为什么用子查询校验而非先查后更新若采用「SELECT stock FROM goods... → 判断 → UPDATE goods SET stockstock-?」两步操作在并发场景下必然出现超卖。本项目在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(); // 从数据库查用户角色及权限码 ListString 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 ResultListGoods listGoods() { ... } PostMapping(/goods) PreAuthorize(hasPermission(admin,goods:write)) public ResultVoid addGoods(RequestBody Goods goods) { ... } }user_permission表结构文档第 13 页含username、permission_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 ResultVoid createPurchaseOrder(PurchaseOrder order) { // 步骤1保存采购单头 purchaseOrderMapper.insert(order); // 步骤2批量保存采购明细 ListPurchaseItem 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 ResultVoid createSaleOrder(SaleOrder order) { // 1. 校验客户是否存在调用 CustomerService customerService.findById(order.getCustomerId()); // 2. 校验每件商品库存关键批量校验非逐条 ListLong goodsIds order.getItems().stream() .map(SaleItem::getGoodsId).collect(Collectors.toList()); MapLong, 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聚合一次查询返回全部商品当前库存避免 N1 查询。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意CHECK约束在 MySQL 5.7 及以下版本不生效项目文档要求 MySQL 版本 ≥ 8.0.13。若部署环境为旧版 MySQL需在应用层严格校验并移除该约束。4. 启动与调试实战从源码运行到定位库存扣减失败原因4.1 环境准备与快速启动项目基于 Spring Boot 2.7.xpom.xml中spring-boot.version2.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?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue 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 >-- 查询某商品ID1001的最新库存日志 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_stock与goods.stock不符说明存在未同步的库存变更。常见原因手动执行了UPDATE goods SET stock ?绕过日志表inventory_log表有脏数据如log_typeIN但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 日志与监控配置要点项目使用 Logbacklogback-spring.xml中配置了inventory包的 DEBUG 级别日志便于追踪库存变更!-- logback-spring.xml -- logger namecom.example.supermarket.service.inventory levelDEBUG/当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的典型场景。项目已预留该类型但未提供前端入口。扩展步骤如下新增 Controller 接口AdjustmentController.javaRestController RequestMapping(/api/adjustment) public class AdjustmentController { PostMapping PreAuthorize(hasPermission(admin,adjustment:write)) public ResultVoid createAdjustment(RequestBody AdjustmentRequest request) { // request 包含 goodsId, adjustQuantity正为盘盈负为盘亏, reason inventoryService.adjustStock(request.getGoodsId(), request.getAdjustQuantity(), null, // ref_id 为空因无来源单据 ADJUST, SecurityUtils.getCurrentUserId()); return Result.success(); } }复用InventoryService.adjustStock()方法无需新增逻辑// InventoryService.java 中已有此方法 public void adjustStock(Long goodsId, BigDecimal quantity, Long refId, String refType, Long operatorId) { // 内部调用 inventoryMapper.insertAdjustLog(...) 插入 ADJUST 类型日志 // 并更新 goods.stock 字段 }新增inventory_log插入 SQLInventoryMapper.xmlinsert idinsertAdjustLog parameterTypemap 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() ) /insert5.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天盘点差异」导出功能。本文还有配套的精品资源点击获取