
1. 先想清楚无人超市管理系统到底在管什么1.1 从门店日常运营反推系统模块我接触这类“基于JavaSpring Boot的无人超市管理系统”项目不止一次发现一个共同的毛病一上来就纠结“人脸识别怎么做”“拿了就走怎么实现”结果写了一个月代码连最基础的商品上下架、库存联动、订单状态流转都没捋清楚。实际上名叫“无人”但管理后台的复杂度一点都不比传统超市低。我们把一家中小型无人超市的日常经营拆开看一天要发生的流程大致是这样顾客扫码进店在货架取货到自助结算台或者小程序扫码支付出门时校验订单。店长下班前要核库存、看营业额、查哪些商品卖得快。采购到了新货要入库过期商品要下架盘点时发现账实不符要纠错。把这些动作映射到系统上核心模块就浮出来了商品管理商品分类、商品信息维护、上下架、定价调价、图片管理库存管理入库、出库、盘点、库存流水、低库存预警订单管理下单、支付、取消、退款、订单详情查询会员管理会员注册、充值、积分、消费明细结算与支付对接微信/支付宝、支付回调、对账统计报表营业额日报、热销商品排行、门店经营概览这份需求清单才是管理系统的“灵魂”。识别硬件只负责告诉系统“顾客拿了什么”而商品怎么维护、库存怎么扣、订单怎么流转、账怎么算全部要靠后台管理系统来承接。1.2 角色权限怎么划分才不浪费这种项目不需要把权限设计得非常复杂但也不能完全放开。常见的角色就三类系统管理员拥有全部权限包括账号管理、系统设置、数据字典门店运营人员日常管理商品、处理入库/盘点、查看订单、导出报表顾客通过小程序或H5端查看自己名下的订单、会员余额、积分权限实现上直接用Spring Boot的拦截器自定义注解基本够用把需要校验的接口标注一下在拦截器里根据登录用户角色放行。不是所有项目都要上Spring Security或者Sa-Token那一套完整链路如果团队不熟悉反而会增加配置成本。我在项目里通常的做法是登录成功后生成tokenRedis里存用户信息和角色标识写一个AuthInterceptor读取请求头里的token鉴权通过后把用户ID塞进ThreadLocal。角色判断只做在Controller层的注解上逻辑简单、容易排查对毕业设计或者中小型后台系统来说这个方案远比引入重型框架稳妥。1.3 系统的边界哪些东西不该自己做做无人超市管理系统最怕边界不清。人脸识别、视觉商品识别、智能称重、门禁硬件控制这些是硬件厂商和算法团队的工作。管理系统要做的是接住硬件传回来的结果然后驱动业务流转而不是自己实现图像识别模型。举个实际例子顾客从货架拿了三件商品到自助结算台视觉识别设备返回一个商品条码列表管理后台真正要做的是“接收条码列表、校验商品状态、计算金额、生成订单”。如果你把精力花在研究视觉模型上项目周期会迅速失控。明确这条边界后系统架构会清晰很多后续接硬件也方便。2. Spring Boot工程搭建与分层设计2.1 技术选型为什么是这套组合先交代一下我惯用的技术栈基本就是国内中小型Java项目最常见的组合组件推荐版本作用JDK1.8 / 11运行环境Spring Boot2.7.x后端开发框架MyBatis-Plus3.5.x数据持久层简化单表CRUDMySQL8.0核心数据存储Redis6.x / 7.x缓存、购物车、验证码、分布式锁MinIO8.x商品图片等文件存储Maven3.8依赖管理与项目构建选Spring Boot的原因很直白生态成熟、资料多、起步快尤其适合做管理系统这类CRUD密集型业务。MyBatis-Plus能省掉大量重复的单表增删改查代码自动填充、逻辑删除、分页插件都是日常高频功能用起来很顺手。Redis用来做验证码存储、购物车临时数据和防重复提交基本是标配。MinIO负责商品图片自己部署一个私有化存储比把文件直接扔本地磁盘更规范热词里也提到过minio加入到springboot这条路在无人超市场景很实用。这里要提醒一句不要一上来就追新版。Spring Boot 3.x要求JDK17起步部分第三方组件跟着要换包名、换版本遇到问题排查成本高。Spring Boot 2.7.x在JDK8/11下运行稳定对于管理后台项目完全够用。等熟练了再去尝鲜不迟。2.2 工程结构按业务模块划分别按技术层堆类很多初学者喜欢这样建包controller包下面把全部Controller堆进去service包下面堆几千个Service类。代码一多就乱套。我推荐按业务模块分包同一个模块的Controller、Service、Mapper放在一起com.unmanned.supermarket ├── common # 通用返回结果、统一异常、常量 ├── config # Redis、MyBatis-Plus、MinIO等配置类 ├── module │ ├── admin # 管理员账号、登录、权限 │ ├── member # 会员管理 │ ├── goods # 商品、分类、库存 │ ├── order # 订单、订单明细、支付记录 │ ├── report # 报表统计 │ └── stock # 入库、出库、盘点 └── utils # 工具类这种结构的好处是遇到订单问题直接进order包找不用在几十个类里翻。包之间的调用关系也清晰后续拆微服务时直接按模块切就行。2.3 从配置到启动最容易被环境卡住的环节初次搭建Spring Boot项目很多人卡住的不是代码而是环境和构建工具。先检查这几项java -version mvn -version mysql --version redis-cli ping我自己踩过的最典型的一个坑JDK17的环境里跑Spring Boot 2.7启动时报各种反射异常折腾了很久才发现是版本不匹配。所以项目创建前先统一JDK版本建议直接用JDK8或者11。另一个坑是Maven构建时下载依赖极慢可以换国内镜像源在settings.xml里配好阿里云镜像。pom.xml里核心依赖按照这个清单来加spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、spring-boot-starter-data-redis、lombok、spring-boot-starter-validation、minio、jjwt生成登录token。构建命令很简单mvn clean package -DskipTests构建出的jar包会生成在target目录。本地运行时我习惯直接在IDEA里配一个Spring Boot启动类加上application-dev.yml开发环境配置端口用8080。3. 数据库设计把货、人、单、库存四条线理清楚3.1 核心表结构与关系无人超市管理系统的表设计我总结为四条主线货、人、单、库。货即商品商品分类表goods_category商品表goods商品图片可以单独建一张goods_image也可以直接在商品表里存图片URL地址。人即会员member会员表。单即订单orders订单主表 order_item订单明细表 pay_record支付记录表。库即库存goods_stock实时库存表 stock_flow库存流水表。下面给出几个核心表的建表语句简化版CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_name VARCHAR(100) NOT NULL COMMENT 商品名称, goods_code VARCHAR(64) NOT NULL COMMENT 商品编码/条码, category_id BIGINT COMMENT 分类ID, price DECIMAL(10,2) NOT NULL COMMENT 销售单价, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, image_url VARCHAR(255) COMMENT 商品图片, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_goods_code (goods_code) ) COMMENT 商品表;CREATE TABLE goods_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, stock INT NOT NULL DEFAULT 0 COMMENT 实时库存, warning_stock INT DEFAULT 10 COMMENT 低库存预警阈值, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_goods_id (goods_id) ) COMMENT 商品实时库存表;CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号, member_id BIGINT COMMENT 会员ID, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, status TINYINT NOT NULL COMMENT 订单状态: 0待支付 1已支付 2已完成 3已取消 4退款中 5已退款, pay_type TINYINT COMMENT 支付方式: 1微信 2支付宝, pay_time DATETIME COMMENT 支付时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) COMMENT 订单主表;订单明细表order_item必须冗余商品快照信息下单时的商品名称、单价、数量、图片。商品后续改价不影响历史订单的记录。3.2 库存流水表为什么不直接改库存字段这是数据库设计里最关键的一个理念。初学者习惯在goods表里加一个stock字段下单就stock-1入库就stock1。这样做的问题是库存变动没有痕迹。一旦数据对不上完全不知道是哪笔操作造成的。正确做法是goods_stock表只保存最新库存数字所有增减操作都向stock_flow流水表插入一条记录。CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, change_num INT NOT NULL COMMENT 变动数量正数为入库负数为出库, stock_after INT NOT NULL COMMENT 变动后的库存, biz_type TINYINT NOT NULL COMMENT 业务类型: 1采购入库 2销售出库 3盘点调整 4退货入库, biz_no VARCHAR(64) COMMENT 关联业务单号, remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 库存流水表;每次修改库存时在同一个事务里做两件事更新goods_stock的库存数量插入一条stock_flow。以后任何一次库存异常都能按流水回溯到具体业务单据。盘点时如果发现账实不符也直接记一条盘点调整流水并写清备注。3.3 订单状态机状态字段必须全项目统一订单状态是整个系统最容易出bug的地方。我在项目里把状态用数字表示状态值含义说明0待支付订单已生成等待用户支付1已支付支付成功支付金额已入账2已完成顾客取货离店订单完成3已取消用户主动取消或超时未支付自动取消4退款中售后处理中5已退款退款完成关键一点状态流转逻辑要收敛到Service层比如OrderService.changeStatus(oldStatus, newStatus, orderNo)禁止在每个Controller里直接写order.setStatus(3)。系统里必须定义好常量枚举比如OrderStatusEnum.PAID.getCode()。整个项目统一引用才不容易出现“这里用1表示已支付那里用1表示待支付”的灾难。4. 核心流程实现扫码进店、自助结算、库存联动4.1 进店与会员身份识别无人超市的进门逻辑通常是顾客在门口扫二维码二维码里带着会员身份标识系统校验通过后开门同时把入场记录写进Redis。这里有个安全细节二维码不能直接用会员ID明文生成否则被人截图转发就能随意进店。我采用一次性token的方案会员请求“进店二维码”接口时系统生成一个随机字符串作为token存入Rediskey为enter:token:{token}value是会员ID有效期2分钟。门禁设备拿着token调用系统接口校验校验通过就开门同时删除该token确保只能用一次。出门时校验逻辑类似顾客完成支付后系统在Redis里写入该会员的“已完成订单”标记门禁设备查询到有效标记后放行。这套设计把身份识别与业务系统解耦后面换成刷卡、人脸识别只需要替换生成token的入口。4.2 下单结算购物车与订单生成无人超市的自助结算流程核心是购物车。购物车有两种实现Redis hash结构或者MySQL临时表。我建议放在Redis因为购物车生命周期短、读写频繁非常适合缓存场景。Autowired private StringRedisTemplate redisTemplate; // 加入购物车 public void addCart(Long memberId, Long goodsId, Integer num) { String cartKey cart: memberId; redisTemplate.opsForHash().increment(cartKey, String.valueOf(goodsId), num); } // 获取购物车 public MapObject, Object getCart(Long memberId) { String cartKey cart: memberId; return redisTemplate.opsForHash().entries(cartKey); }提交订单时按购物车里的商品ID批量查出数据库里的商品价格逐一计算金额绝不能信任前端传过来的单价和总价。这是支付类项目的铁律。订单主表和明细表在一个事务里插入生成全局唯一的订单号然后调用支付接口。订单号生成建议直接用时间戳随机数业务前缀或者用数据库自增ID配合Redis自增。只要保证不重复即可。4.3 库存扣减并发下的正确姿势这是无人超市系统里最核心的技术点之一也是面试时最常被追问的问题。很多人的第一版代码是// 错误示范 GoodsStock stock stockMapper.selectById(goodsId); if (stock.getStock() num) { throw new BusinessException(库存不足); } stock.setStock(stock.getStock() - num); stockMapper.updateById(stock);这段代码在并发环境下一定会超卖。两个请求同时读到库存为1都判断“库存足够”然后各自减1库存就变成负数了。正确做法是使用条件更新SQLUPDATE goods_stock SET stock stock - #{num} WHERE goods_id #{goodsId} AND stock #{num}注意最后的stock #{num}条件MySQL的UPDATE语句会加行锁只有条件满足时才能更新成功。再判断返回的影响行数如果为0说明库存不足直接抛出业务异常事务回滚。配合MyBatis-Plus可以这样写Transactional(rollbackFor Exception.class) public void deductStock(Long goodsId, Integer num) { int affectRows goodsStockMapper.deductStock(goodsId, num); if (affectRows 0) { throw new BusinessException(库存不足); } // 插入库存流水 stockFlowMapper.insert(StockFlow...); }上面这个写法在普通业务量级下已经完全够用。如果业务量大到同一商品频繁被抢购可以再加Redis分布式锁但这种无人超市场景基本不需要。优先用数据库能力解决再谈加缓存锁这是我多次实践后的体会。4.4 支付回调顺序、重复、乱序都要兜住微信支付和支付宝的回调都是异步通知同一个支付结果可能会推送多次顺序也可能和预期不一致。处理支付回调的核心原则是先查状态再做流转变更保证幂等。我实现的逻辑大致是这样Transactional(rollbackFor Exception.class) public void handlePayCallback(String orderNo, String payBillNo, BigDecimal amount) { Orders order orderMapper.selectByOrderNo(orderNo); if (order null) { throw new BusinessException(订单不存在); } // 订单已经是已支付状态说明是重复通知直接返回成功 if (OrderStatusEnum.PAID.getCode() order.getStatus()) { return; } // 只有待支付状态才能变成已支付 if (OrderStatusEnum.WAIT_PAY.getCode() ! order.getStatus()) { throw new BusinessException(订单状态异常无法支付); } order.setStatus(OrderStatusEnum.PAID.getCode()); order.setPayTime(new Date()); orderMapper.updateById(order); // 同时写入支付记录表 payRecordMapper.insert(PayRecord...); }注意更新订单状态和写入支付记录必须在同一事务里否则可能出现在极端情况下订单已支付但支付记录丢失的情况。最后给支付平台返回“success”字符串告诉它别继续通知了。5. 无人超市系统最容易踩的坑与排查全过程5.1 超卖问题一次完整的定位复盘我在测试环境里复现过这个经典bug。背景商品A库存100件测试时两个账号同时下单接口并发压测后库存变成负数。排查过程第一步看日志。发现两个请求的SQL日志都是先select stock一个读到90另一个也读到90然后各自执行update stock90-1最终库存变成89少扣了一次。第二步定位根因。问题出在“先查后改”的竞态条件查询和更新之间不是原子操作两个线程可以同时读到同一个旧值。第三步修复。把扣减逻辑改成第四条讲的stock num条件更新在数据库层面保证原子性而不是在Java代码层面判断。第四步验证。重新压测100个并发请求库存始终准确多扣一单都不会发生。这个坑几乎每个做库存系统的人都会踩关键是不要等到上线被线上用户教做人开发阶段就该用并发测试工具压一遍下单接口。5.2 接口幂等用户连点支付按钮怎么办无人超市自助结算时用户如果连续点了两次“提交订单”会发生什么如果没有幂等处理就会生成两笔订单用户付两次款。最简单的兜底方案提交订单接口增加防重逻辑。用Redis的SETNX命令以“用户ID随机业务标识”作为key几秒内重复请求直接拒绝public boolean tryLock(String key, long expireSeconds) { Boolean flag redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(flag); }订单创建完成后再把防重key删除。这个方案虽然不能覆盖所有极端情况但对付用户手抖连点已经足够了。5.3 商品识别方案二维码、RFID还是视觉识别无人超市的商品识别没有统一标准不同场景方案差异很大方案优点缺点适用场景商品二维码/条码扫描成本低、稳定需要顾客主动扫码小型无人货柜、自助收银台RFID标签识别速度快、可批量读取标签成本高、干扰敏感中型无人超市视觉识别体验好、接近“拿了就走”算法复杂、准确率受环境影响大型无人超市后台管理系统层面正确的做法是定义统一的识别结果接口把硬件差异隔离在实现层。比如public interface GoodsIdentifyService { ListGoodsIdentifyResult identify(ListString rawData); }二维码方案和RFID方案各写一个实现类用配置项切换。核心订单流程完全不用改。这种面向接口编程的思路是这个项目里最有价值的设计决策之一。5.4 报表统计的慢查询优化营业额报表页在数据量上来后会明显变慢。原因是每次打开报表都实时对订单明细表做SUM、GROUP BY订单表几万条数据时还能撑住超过十万条就开始卡了。我采用的方案是预聚合日结表。每天凌晨定时任务跑一次把前一天的各分类销量、销售额统计好存入daily_sales_summary表。报表页面只查聚合表不碰明细表。查询耗时从秒级降到毫秒级。日结任务失败要有告警和重跑机制保证数据不丢。这套思路在很多管理后台里都通用。6. 从开发到部署上线前必须做的事6.1 环境检查清单上线前先把自己本地能跑通的东西列成清单逐项确认JDK与Maven环境变量已配置命令行能正常执行java -version和mvn -versionMySQL连接串正确8.x版本特别注意URL里要带serverTimezoneAsia/Shanghai和useUnicodetruecharacterEncodingutf8Redis可正常连接密码配置在application配置文件中MinIO中已创建存储桶并配置了公网可访问的地址Spring Boot配置文件按环境区分application-dev.yml、application-prod.yml很多项目上线就挂不是代码问题而是环境连接串不对。尤其是MySQL时区问题开发时用默认配置能跑生产环境一连接就报错这是老生常谈的坑。6.2 打包部署jar包 Nginx反向代理前端后端分离的项目部署方案我推荐后端打包成jar包通过nohup命令后台运行日志输出到指定文件nohup java -jar /opt/supermarket/app.jar \ --spring.config.location/opt/supermarket/application-prod.yml \ --server.port8080 \ /opt/supermarket/logs/app.log 21 注意spring.config.location参数配置文件外置是个很容易被忽略但极其重要的习惯。改数据库密码、改Redis地址不用重新打包只需编辑外部配置文件然后重启即可。前端Vue项目执行npm run build生成dist目录丢给Nginx托管Nginx配置反向代理location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这样前端访问/api/xxx时请求会转发到Spring Boot服务。Nginx直接托管静态文件性能和稳定性都比直接访问后端强不少。6.3 上线后的监控日志要落盘核心链路要有痕无人超市系统涉及真实交易上线后的可观测性非常重要。我至少会做三件事统一日志格式所有接口入口打请求日志支付回调等关键节点打info日志异常打error日志并记录订单号、会员ID等上下文。线上出了问题第一时间看日志定位。连接池和缓存监控数据库连接池和Redis连接数需要设置合理上限一旦超过阈值要能查出来。Spring Boot的Actuator自带的/actuator/health接口就能做基础检查。支付回调失败告警如果支付回调一直失败用户可能付了钱但订单没更新这是最严重的客诉场景。在回调处理逻辑里加失败计数超过一定次数立刻通知开发人员介入。最后再分享一个个人心得无人超市这个项目“无人”只是业务形态真正的技术含量全在“管理”二字。商品、库存、订单、支付、结算、权限、报表每一条线拆开都是很经典的Java业务开发命题。把并发扣库存、支付幂等、状态机这些基本功练扎实了这套系统的骨架就能稳稳撑住后面接任何新硬件、新渠道都不怕。希望这篇梳理能给你一些参考正在做类似项目的朋友可以照着这套思路往下落地。