news 2026/11/5 9:01:31

Spring Boot小型超市管理系统:库存扣减与事务处理实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot小型超市管理系统:库存扣减与事务处理实战解析

简介:针对区域性小型商超在供应链信息孤岛、动态决策支持不足等痛点,该资源提供了一套基于SpringBoot与MySQL的完整管理系统设计与实现方案,面向计算机相关专业毕业设计者、Java学习者及中小型超市信息化建设人员。压缩包内402个文件,涵盖158个Java后端源码、58个Vue前端组件、30个JavaScript及HTML/CSS样式文件,并配齐SQL数据库脚本与项目配置文档,可满足从环境搭建、编码实现到系统部署的全流程参考。包体整体大小约13.18MB,目录结构清晰,便于按功能模块检索。当前已有147人学习下载,资源结合商品分类、采购流程与销售数据分析等核心业务模块,同时提供数据库脚本、技术文档及运行截图,能有效帮助读者理解B/S架构下前后端交互逻辑,降低自主开发与论文撰写难度。

1. Spring Boot小型超市管理系统:不用从零写,但也不是拿来就能交

花一晚上跑通一个 Spring Boot 小型超市管理系统,听起来足够应付毕业设计了。但真正动手的人会发现问题:商品、库存、销售、会员四件事看着各管各的,连起来全是细节——库存到底在哪个环节扣、卖一笔送多少积分、收银中途失败要不要整体回滚。这套资源恰好是把这些环节跑通的完整后端代码、MySQL 数据库脚本和配套说明文档(LW)。它不是那种只把页面堆出来就收工的 Demo,而是把业务表怎么建、事务放在哪一层、库存扣减怎么防超卖都交代清楚的落地项目,适合正在做同类型选题、又不想在基础设施上耗时间的同学。

2. 模块拆解与 MySQL 建表:七张表守住商品、库存、销售、会员

2.1 功能边界先划清:哪些模块是你真正要写的

打开这套代码,先别急着跑,先把功能边界认一遍。小型超市管理系统最核心的模块就四个:商品管理、库存管理、收银销售、会员积分。外围再配一个后台登录和用户管理。

这四个模块之间是有依赖顺序的。商品管理管的是“超市里有什么”,库存管的是“每样商品还剩多少”,销售管的是“今天卖了什么”,会员管的是“卖给谁了、攒了多少分”。我拿到这套资源后的第一件事,就是把这些功能从 Controller 层到 Service 层逐个对应到数据库表上,确认没有“代码里明明写了库存却找不到对应字段”的情况。

这套资源的后端技术栈很常规:Spring Boot 2.x + MyBatis + MySQL 8.0,前端是普通的 HTML/JS 静态页面,没有引入复杂的前端框架。好处是你不用为环境折腾太久,本机装了 JDK 1.8 和 MySQL 就能跑起来。

2.2 七张核心表的字段设计与建表理由

数据库设计决定业务代码好写还是难写。这套资源的表结构我只挑核心的表来说,第一张是商品表:

CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品ID', `name` VARCHAR(64) NOT NULL COMMENT '商品名称', `category_id` BIGINT NOT NULL COMMENT '商品分类ID', `price` DECIMAL(10,2) NOT NULL COMMENT '售价', `stock` INT NOT NULL DEFAULT 0 COMMENT '当前库存', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1上架 0下架', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

这里有几个字段值得细说。price用DECIMAL(10,2)而不是DOUBLE,是为了避免金额精度丢失,这一点在 Java 侧也要配合使用BigDecimal,后面避坑章会专门讲。stock用INT,如果超市规模不大完全够用。version这个字段是给乐观锁用的,库存扣减时判断版本号,防超卖的关键就在这里。

再看销售相关表。销售分为单头和明细两张表,这是典型的主从表设计:

CREATE TABLE `sale_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '销售单ID', `order_no` VARCHAR(32) NOT NULL COMMENT '销售单号', `member_id` BIGINT DEFAULT NULL COMMENT '会员ID,空为散客', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '实收金额', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售单主表'; CREATE TABLE `sale_detail` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `sale_id` BIGINT NOT NULL COMMENT '关联销售单ID', `product_id` BIGINT NOT NULL COMMENT '商品ID', `product_name` VARCHAR(64) NOT NULL COMMENT '冗余商品名称', `price` DECIMAL(10,2) NOT NULL COMMENT '成交单价', `quantity` INT NOT NULL COMMENT '购买数量', `subtotal` DECIMAL(10,2) NOT NULL COMMENT '小计金额', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售明细表';

product_name在明细表里冗余了一份,这个设计初看违反范式,但在实际超市系统里很实用。商品改了名字,历史销售单里的商品名不会被连带修改,查账的时候更稳。sale_order里的order_no加了唯一索引,防止并发下单时生成重复订单号。

会员表这块,常见设计是会员基础信息 + 积分流水表。基础信息表存手机号、姓名、累计积分;积分流水表记录每一笔积分的来源(消费赠送、手动调整等)。这套包里积分计算规则很简单:消费 1 元积 1 分,凑整计算。规则本身写在 Service 层,想改成满 10 元积 1 分,改一个除法参数就行。

2.3 包结构与应用配置:启动前要检查的三个参数

拿到代码包,先看包结构。常见分层是 controller / service / dao / entity / config 五层。这套资源里的代码结构也基本沿用这个套路,好处是每一层职责清楚,答辩时也好讲。

# application.yml 核心配置片段 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai server: port: 8080 servlet: context-path: /supermarket

启动前有三个地方容易翻车。第一,driver-class-name必须是com.mysql.cj.jdbc.Driver,这是 MySQL 8.0 的驱动类,写成旧版的com.mysql.jdbc.Driver会直接报类找不到。第二,serverTimezone=Asia/Shanghai不配,数据库连接时会报时区错误,这是 MySQL 8.0 之后的强制要求。第三,context-path配了/supermarket,那么所有接口的前缀都带/supermarket,前端页面的请求路径也得跟着改,不然后端能通、前端全 404。

3. 商品管理与库存扣减:超卖问题就靠一条 UPDATE 解决

3.1 商品查询接口:分页和条件筛选是答辩必问点

商品管理模块里最常被问到的是分页查询怎么写的。这套资源用的是 MyBatis 手写 SQL,分页参数通过PageHelper插件处理。Controller 层接收pageNum和pageSize,Service 层调用查询方法,返回分页结果集。

@GetMapping("/product/list") public Result<PageInfo<ProductVO>> list( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) Long categoryId) { ProductQuery query = new ProductQuery(); query.setPageNum(pageNum); query.setPageSize(pageSize); query.setKeyword(keyword); query.setCategoryId(categoryId); PageInfo<ProductVO> page = productService.pageQuery(query); return Result.success(page); }

defaultValue = "1"和defaultValue = "10"这两个参数是给前端的兜底,防止未传参时接口报错。keyword是模糊搜索商品名用的,categoryId用于按分类过滤。这里我一般会让ProductQuery继承一个通用的分页基类,把pageNum、pageSize放公共字段里,代码更整洁。

对应的 Mapper XML 里,模糊查询的写法要注意防止 SQL 注入风险:

<select id="pageQuery" resultType="com.supermarket.entity.Product"> SELECT * FROM product <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> AND status = 1 </where> ORDER BY id DESC </select>

用<where>标签代替手工拼接WHERE 1=1,用CONCAT('%', #{keyword}, '%')接受参数而不是直接拼进 SQL,这两点都是面试和答辩中容易提到的加分项。

3.2 库存扣减的正确姿势:先查再改是万恶之源

超市收银的场景是:前端把购物车商品列表提交到后端,后端逐件扣库存。很多初学者写库存扣减是三步走——先 SELECT 查库存,再在 Java 里判断库存是否充足,最后 UPDATE。这套代码最大的价值在于,它用了更可靠的原子扣减。

@Transactional(rollbackFor = Exception.class) public boolean deductStock(Long productId, int quantity) { if (quantity <= 0) { throw new IllegalArgumentException("购买数量必须大于0"); } int rows = productMapper.deductStock(productId, quantity); if (rows == 0) { throw new RuntimeException("库存不足,商品ID:" + productId); } return true; }
<update id="deductStock"> UPDATE product SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{id} AND stock >= #{quantity} </update>

逻辑说明:这一步的关键在WHERE stock >= #{quantity}。MySQL 执行 UPDATE 时会先定位满足条件的行,再把stock减去数量。如果库存不够,影响行数是 0,Java 侧拿到 0 就知道扣减失败,直接抛异常让事务回滚。

参数说明:quantity是本次购买数量;rows是数据库返回的影响行数,这是判断成功与否的唯一依据。代码里不需要先 SELECT 再判断,一次 UPDATE 既完成了“校验库存”又完成了“扣减库存”。

这套逻辑即使两个请求同时来买同一件只剩 1 件的商品,数据库的行锁也会让其中一个 UPDATE 等待,等锁释放后另一个发现stock < quantity,影响行数为 0,直接失败。这就是防超卖的底层机制。

3.3 乐观锁字段 version 的用途和取舍

商品表里的version字段,在这套资源里承担的是乐观锁职责。有人会问,上面已经用stock >= #{quantity}防超卖了,还要 version 干什么?

答案是双保险。stock >= #{quantity}解决的是“库存数量不够”的问题;version解决的是“基于旧版本修改导致的数据覆盖”问题。比如后台管理员编辑商品价格时,读取到了旧的 version=3,另一个管理员同时改了价格使 version 变成 4,前一个提交时 WHERE version=3 匹配不到行,更新就失败了。

<update id="updatePrice"> UPDATE product SET price = #{price}, version = version + 1 WHERE id = #{id} AND version = #{version} </update>

使用乐观锁时,Java 侧要处理更新失败的重试逻辑。常见做法是捕获影响行数为 0 的情况,重新查询最新数据再让用户确认。这套代码里库存扣减用原子 UPDATE,编辑场景用乐观锁,两者搭配是成熟项目的常见设计,答辩时能说清楚这一点,比背十页 PPT 都管用。

4. 收银结算与会员积分:一个事务里藏着三个坑

4.1 收银结算的完整链路:单头、明细、库存、积分怎么串起来

收银是超市系统的核心场景,前端扫码或手动选中商品,点击结账后后端要做四件事:生成销售单主表记录、写入销售明细表、扣减库存、如果是会员则累计积分。这四件事必须在一个事务里完成,否则可能出现“钱收了但库存没扣”或“库存扣了但积分没记”的脏数据。

@Transactional(rollbackFor = Exception.class) public SaleResponse createSale(SaleRequest request) { // 1. 生成销售单主表 SaleOrder order = new SaleOrder(); order.setOrderNo(generateOrderNo()); order.setMemberId(request.getMemberId()); order.setTotalAmount(request.getTotalAmount()); saleOrderMapper.insert(order); // 2. 循环写入销售明细,同时扣减库存 for (SaleItemDTO item : request.getItems()) { SaleDetail detail = new SaleDetail(); detail.setSaleId(order.getId()); detail.setProductId(item.getProductId()); detail.setProductName(item.getProductName()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); detail.setSubtotal(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); saleDetailMapper.insert(detail); // 库存扣减 int rows = productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("库存不足:" + item.getProductName()); } } // 3. 处理会员积分 if (request.getMemberId() != null) { memberService.addPoints(request.getMemberId(), request.getTotalAmount().intValue()); } SaleResponse response = new SaleResponse(); response.setOrderNo(order.getOrderNo()); response.setTotalAmount(request.getTotalAmount()); return response; }

逻辑说明:方法从上到下依次执行,任何一个环节抛异常,@Transactional会让前面已经执行的数据库操作全部回滚,包括已插入的销售单主表和明细。注意rollbackFor = Exception.class这个参数,Spring 默认只对RuntimeException回滚,如果业务方法里抛的是受检异常,不加这个参数事务就不会回滚。

参数说明:SaleOrder是主表单对象,SaleItemDTO是前端传入的购买项,包含商品 ID、名称、单价、数量。totalAmount我用BigDecimal接收,避免 double 转字符串出现多余小数位。generateOrderNo()是自定义的单号生成方法,常见做法是yyyyMMddHHmmss + 随机数。

4.2 会员积分累计的幂等问题

积分累计这里有个容易被忽略的坑。如果支付成功后网络抖动,前端重试提交了同一个订单,后端如果没有做幂等处理,同一笔交易可能被插入两次销售单、扣两次库存、积两次分。

这套资源里的做法是:前端生成一个唯一的请求流水号,后端在创建销售单前先查这个流水号是否已经存在,存在就直接返回已有结果,不再重复执行业务。

// 幂等校验:根据请求唯一流水号查询是否已处理 String requestNo = request.getRequestNo(); if (requestNo != null && saleOrderMapper.countByRequestNo(requestNo) > 0) { throw new DuplicateOrderException("重复提交的订单"); }

sale_order表里建议加一个request_no字段并设置唯一索引。数据库层面的唯一约束是最后一道防线,比 Java 代码里的判断更可靠,因为并发场景下两个请求可能同时通过 Java 层的校验,但数据库索引会让其中一个插入直接失败。

4.3 事务边界在哪:Service 层还是 Controller 层

很多初学 Spring Boot 的人会把@Transactional加到 Controller 方法上,这套资源的代码里事务都放在 Service 层,这个选择是有道理的。

Controller 层的职责是参数接收、参数校验、结果封装,它不该关心数据库事务。事务放在 Service 层,保证的是“业务逻辑完整性”。假设一个场景:Controller 里先调用了订单 Service 的创建方法,又调用了短信 Service 的发送通知方法,事务放在 Controller 层会把短信发送也卷进同一个事务,短信服务器响应慢的时候,数据库连接会被长时间占用,这是很典型的性能隐患。

这套代码里 Service 层方法的粒度控制得比较清楚:一个方法对应一个完整的业务操作,内部调用多个 Mapper 方法。事务加在最外层 Service 实现类的方法上,内部各 Mapper 操作共享同一个连接,这一点在答辩时讲出来,是明显的加分项。

5. 常见问题与排查:五次翻车记录,照着改就能跑

5.1 启动报错:找不到驱动类或时区异常

现象:项目启动后控制台报ClassNotFoundException: com.mysql.jdbc.Driver,或者报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。

原因:驱动类写成了 MySQL 5.x 时代的com.mysql.jdbc.Driver,且连接串里没带serverTimezone参数。MySQL 8.0 之后驱动改为com.mysql.cj.jdbc.Driver,时区参数必须显式指定。

解决:对照 2.3 节的application.yml改驱动类名和连接串,加上serverTimezone=Asia/Shanghai。注意如果是用 Docker 装的 MySQL,url里的localhost要改成容器映射出来的 IP,否则会报连接拒绝。

5.2 金额计算出现 19.899999 这种奇怪结果

现象:前端页面显示总价 19.899999,或者数据库里存的金额长了尾巴。

原因:Java 侧用了double类型做金额计算。0.1 + 0.2在二进制浮点里等于0.30000000000000004,这是 IEEE 754 的先天缺陷,不是超市系统特有的问题。

解决:Java 侧接收金额参数全部改用BigDecimal,构造函数用字符串入参的版本,比如new BigDecimal("19.90"),不要用new BigDecimal(19.9)。数据库字段类型用DECIMAL(10,2)。这套资源里实体类的金额字段已经是BigDecimal,如果自己另写了转换逻辑,注意别把BigDecimal转回double再计算。

5.3 并发测试时库存被扣成负数

现象:用 Jmeter 同时发 10 个请求买同一件只剩 5 件库存的商品,最终库存变成负数。

原因:库存扣减代码写成了“先 SELECT 查库存,再在 Java 里 if 判断够不够,最后 UPDATE 减库存”。两个请求同时查到库存为 5,都通过了 Java 层的 if 判断,然后先后执行 UPDATE,库存就变成了 3 和 -1。

解决:改成 3.2 节的原子扣减,一条 UPDATE 语句完成校验和扣减,通过影响行数判断是否成功。如果包里的代码已经是这个写法还出现了负数,检查是不是 Mapper 里 SQL 写成了SET stock = #{stock} - #{quantity}而不是SET stock = stock - #{quantity}——前者用的是旧值覆盖,丢掉了并发下的更新。

5.4 配套文档(LW)和代码实际逻辑对不上

现象:答辩前照着文档讲表结构,讲到某一处,导师翻开代码一核对,发现字段名或者功能描述不一致。

原因:文档是早期版本,代码后续改过需求,两边没有同步更新。这类资源和文档分离的项目包里很常见,文档停留在初版设计,代码已经迭代了好几轮。

解决:拿到资源后第一天就做文档与代码的对照核查。建一张核对表,左边列文档里的功能描述,右边列代码里的实际实现,对不上的地方用批注标出来。答辩时主动说“文档里第 X 处描述和代码有差异,以代码为准”,比被导师发现要好得多。这是血泪经验,我见过不止一个同学栽在这上面。

5.5 前端页面请求后端接口全部 404

现象:系统能启动,登录页能打开,但一点登录按钮就报 404,Network 面板里看到请求地址是http://localhost:8080/user/login。

原因:application.yml里配了context-path: /supermarket,所有接口的真实路径是http://localhost:8080/supermarket/user/login,前端没带这个前缀。

解决:两个思路选一个。一是前端 JS 里的请求基础路径统一加上/supermarket前缀;二是不用context-path,直接去掉这个配置,保持后端接口根路径。这套资源默认是有前缀的,如果是第一次跑,优先改前端请求地址,保留后端统一前缀,后续部署到 Nginx 反向代理时更灵活。

6. 答辩前一天的验证:十分钟跑通演示的说辞与清单

毕业设计答辩当天最怕的不是功能缺,而是演示环节卡壳。我自己就栽过一次:提前两天没碰代码,答辩当天启动项目,数据库连不上,页面白屏,硬着头皮讲了五分钟 PPT,导师问了一句“你确定系统跑起来过吗”,当场被噎住。从那以后,我每次拿到的项目包,都会先在本地强制走一遍完整的验证清单再做其他事,这里把清单分享给你。

步骤操作预期结果预计耗时
1启动 MySQL 服务,导入supermarket.sql控制台显示导入成功,SHOW TABLES能看到 7 张表2 分钟
2启动 Spring Boot 项目控制台出现Started日志,无异常堆栈1 分钟
3浏览器访问登录页页面正常渲染,登录后跳转首页1 分钟
4新增一件商品,库存设为 100商品列表出现新数据,库存显示 1001 分钟
5模拟一笔收银,购买 3 件销售单生成,库存变为 97,积分按规则累计2 分钟
6用错误的密码连续登录 5 次页面出现账号锁定提示1 分钟
7清空浏览器缓存,重新访问需要重新登录,证明登录态已失效1 分钟

验证完功能后,用下面两条 SQL 做数据层面的自检,确认下单后的数据没有脏数据:

-- 查询商品库存与扣减记录 SELECT id, name, stock, version FROM product WHERE id = 1; -- 核对销售单与明细的数量一致性 SELECT s.id, s.total_amount, COUNT(d.id) AS detail_count FROM sale_order s LEFT JOIN sale_detail d ON d.sale_id = s.id GROUP BY s.id;

搭配这个演示流程,答辩时建议按三条主线来讲。第一条从数据库出发,说明表结构为什么这样设计,重点讲product表的version字段和sale_order与sale_detail的主从关系。第二条从一次完整收银出发,现场打开数据库操作日志,展示下单后库存和积分的联动变化,这里的亮点是事务把多步操作粘成整体。第三条从代码质量出发,指出幂等校验和原子扣减这两处是经过并发场景验证的,不是堆功能。

如果时间充裕,还可以演示一个深度操作:把某件商品库存改成 1,同时开两个浏览器窗口一起下单,只有一单能成功,另一单会看到库存不足的提示。这个演示能直观展示乐观锁和原子 UPDATE 的价值,给评委留下的印象远好过单纯的“页面能增删改查”。希望这份拆解和清单帮到你,答辩那天少点意外,多点从容。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 22:08:50

HP 39GII重生记:从教学计算器到嵌入式图形工作站

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/29 4:50:56

瑞数6vmp动态令牌获取全流程:从VM保护拆解到自动化请求的工程实践

简介&#xff1a;面向Web逆向与JavaScript混淆分析场景的瑞数6vmp后缀获取流程项目代码&#xff0c;适用于具备一定JS基础、希望突破瑞数vmp防护逆向瓶颈的学习者和开发者。资源共5个文件&#xff0c;包含2个HTML演示页、1个JS Hook示例脚本、1个inscode环境配置和1个gitignore…

作者头像 李华
网站建设 2026/11/3 18:51:08

wxid转微信好友二维码:离线反查与批量生成工具包

简介&#xff1a;这是一套面向微信普通用户、客服人员及社群运营者的轻量级前端工具包&#xff0c;专为解决已知好友wxid但未保存聊天、或误删好友后需快速重新添加的痛点场景。工具完全本地运行&#xff0c;无需后端服务&#xff0c;不上传任何数据&#xff0c;输入wxid即可生…

作者头像 李华
网站建设 2026/11/4 10:08:23

用Go自研高并发压测工具:微服务性能测试的关键设计与实战复盘

先聊一个我印象很深的事故复盘。某次大促前做全链路压测&#xff0c;线上接口突然大面积超时&#xff0c;七七八八查了一整天&#xff0c;最后定位到根因居然出在压测机器自己身上——本地端口被占满了&#xff0c;请求还没出得去就已经在内核层被拒绝。服务端一点问题没有&…

作者头像 李华
网站建设 2026/11/4 20:31:51

本地离线数字人部署:Wav2Lip+SadTalker全链路实战

简介&#xff1a;这是一套面向PHP开发者与AI应用实践者的本地化数字人克隆系统源码&#xff0c;适用于需私有化部署、定制化改造AI形象生成能力的技术人员。资源完整包含前后端代码、安装文档及运行所需全部依赖结构&#xff0c;解决第三方SaaS平台不可控、模型接口难对接、语音…

作者头像 李华