☰
Spring Boot + 无人超市管理系统设计与实现全解析
2026/10/22 3:04:09 网站建设 项目流程

先说结论:这套系统的技术选型很典型,不算花哨,但胜在扎实。如果你正打算做毕设或者课设,需要一个既能体现完整业务闭环、又不会在技术上过于冒险的题目,Spring Boot + 无人超市管理这个方向非常合适。它覆盖了从用户端扫码进店、自助选购、结算支付,到管理端商品维护、库存监控、订单管理、数据统计的完整链路,适合用来展示你对 Java Web 全栈开发的理解,也方便后续扩展人脸识别、物联网设备对接等进阶功能。

我自己在带学生做类似项目时,见过太多一开始追求高深技术最后烂尾的案例,反而是这种“业务清晰、技术主流、边界可控”的选题能稳步做完。这篇文章就以这个题目为例,把从需求拆解、数据库设计、核心代码实现到常见坑位的完整思路捋一遍,希望能给正在纠结怎么入手的朋友一些可落地的参考。

1. 项目整体设计与思路拆解

1.1 需求定位:无人超市管理系统到底要管什么

很多人一看到“无人超市”就想着要搞人脸识别、重力感应货架、RFID标签,这些确实是无人零售的方向,但如果你是做管理系统,核心不在“识别”上,而在“管理”上。一个合格的无人超市管理系统,首先要回答三个问题:谁来卖、卖什么、卖出去之后怎么管。

放在系统里,就是三个核心角色加一条业务主线:

  • 顾客端(小程序/H5/自助收银台):进店、扫码或输入商品编号、加购、结算、支付、查看订单。
  • 管理端(Web管理后台):商品维护、品类管理、库存管理、订单查看、会员管理、数据统计。
  • 系统支撑:登录认证、权限控制、操作日志。

业务主线就是:顾客进店 -> 选购商品 -> 结算支付 -> 订单生成 -> 库存扣减 -> 数据汇总。

把这个主线和角色理清楚,你再去画用例图、设计数据库,就有据可依了,不会出现“功能是别人的需求文档抄来的,自己却不知道为什么要做这个功能”的情况。

1.2 技术选型:为什么是Spring Boot + MySQL + MyBatis-Plus

这个项目名字里已经明确写了 Spring Boot 和 Java,剩下的技术栈组合需要自己定。我最推荐、也是实测最稳的一套组合是:

技术层次选型方案理由
后端框架Spring Boot 2.x约定优于配置,内嵌Tomcat,独立启动,适合快速开发与部署
持久层框架MyBatis-Plus单表CRUD不用写SQL,分页插件好用,配合条件构造器能省大量代码
数据库MySQL 5.7 / 8.0稳定、主流、资料多,团队协作和运维都方便
权限认证Sa-Token 或 JWT + Spring Security(轻量用Sa-Token)登录态管理简单,拦截器配置直观,适合中小型项目
前端管理端Vue 2 / Element UI 或者直接用 Thymeleaf 模板如果前端基础一般,建议直接用模板引擎,减少跨域和联调成本
接口文档Knife4j(Swagger增强版)接口调试方便,答辩展示效果好
缓存(可选)Redis用于验证码存储、热点商品缓存,可作为加分项

你可能听说过有人用 Shiro,有人用 Spring Security,甚至有人直接用原生的 Session + 拦截器。我的建议是:如果这是课设或毕设,不要在权限框架上花太多时间研究源码,选一个能快速跑通、又能讲清楚原理的就行。Sa-Token 是一个很好的选择,API 简单,官方文档中文友好,自己看一遍就能上手。

1.3 系统架构与目录结构规划

这套系统的架构遵循经典的三层结构:Controller(接口层)-> Service(业务层)-> Mapper(数据访问层)。这也是大多数中小型管理系统的主流写法,层次清晰、便于讲解和扩展。

我规划的标准目录结构大致长这样:

com.unmanned.supermarket ├── controller // 接口层:接收前端请求,返回统一响应 │ ├── admin // 管理端接口(商品、订单、用户、统计等) │ └── customer // 顾客端接口(浏览、购物车、结算等) ├── service // 业务层:核心业务逻辑 │ ├── impl ├── mapper // 数据访问层 ├── entity // 数据库实体类 ├── dto // 前端交互的数据对象 ├── vo // 视图返回对象 ├── config // 配置类(拦截器、跨域、Knife4j等) ├── common // 公共类(统一返回体、异常处理、常量) ├── utils // 工具类(JWT、验证码、金额计算等) ├── interceptor // 拦截器(登录校验、权限校验) └── SupermarketApplication.java

实际开发时,建议严格按照这个分层来写。很多同学为了图快,把业务逻辑直接写在 Controller 里,当时看着爽,后面加需求、排查问题、写文档的时候就会非常痛苦。你答辩的时候,老师大概率会问你“为什么设计成三层结构”,你把这个目录结构一摆,再讲清楚“Controller 只做参数接收和响应包装,Service 负责业务规则,Mapper 负责数据交互”,这一问就过了。

2. 核心模块解析与数据库设计

2.1 核心业务模块拆解

把需求翻译成功能模块,大致需要拆出下面七个主要模块:

  1. 商品管理:商品的增删改查、上下架、按分类筛选、图片上传、价格管理。
  2. 品类管理:商品分类的维护,比如饮料、零食、日用品,用于前台展示和后台筛选。
  3. 库存管理:库存数量维护、入库操作、库存预警。这里要注意“多表联动”,商品删除前要检查是否有关联订单记录;订单创建后要扣减库存,取消订单要回补库存,这是最容易出 bug 的地方。
  4. 顾客端选购:模拟超市购物车的核心流程——查询商品、加入购物车、修改数量、移除商品、清空购物车、结算。
  5. 订单管理:生成订单、支付模拟、订单列表查询、订单详情、取消订单、售后处理。
  6. 会员管理:用户注册登录、会员等级(普通会员、黄金会员等)、积分累计、积分抵扣。
  7. 数据统计:销售额、订单量、热销商品排行、按日/周/月维度的趋势图。这个模块虽然是简单的 SQL 聚合查询,但展示效果最直观,建议一定要做,答辩时加分明显。

2.2 数据库表结构设计要点

数据库是整个系统的地基,设计得好,写代码能少踩很多坑。按照上面的模块,至少需要这几张核心表:

表名说明关键字段
user系统用户(管理端+顾客端可分开或共用)id, username, password, phone, role, create_time
category商品分类id, name, parent_id, sort_order
product商品信息id, name, category_id, price, stock, image, status, sales
cart购物车(如果购物车存在后端数据库)id, user_id, product_id, quantity, create_time
order_info订单主表id, order_no, user_id, total_amount, pay_amount, status, create_time
order_item订单明细表id, order_id, product_id, product_name, price, quantity, subtotal
member_level会员等级(可选)id, level_name, discount, min_amount
stock_log库存变动日志(强烈建议加)id, product_id, change_type, change_quantity, create_time

这里想重点强调两个很容易被忽视的设计细节:

第一个是“订单主表 + 订单明细表”的双表结构。很多初学者会把订单设计成一张表,把商品直接存成字符串塞进去,比如“可乐x2,薯片x1,总价25元”。这样做的后果是:你想统计某个商品的销量时,完全没法用 SQL 查。正确做法是订单主表存订单的整体信息(单号、用户、总价、状态),订单明细表存每一个商品的购买快照(商品名、单价、数量、小计)。注意这里存的一定是“快照”,也就是当时下单时商品的价格和名称,而不是去关联 product 表。因为商品名称和价格随时可能改,如果两张表通过外键关联,历史订单的数据就会被篡改,这在业务上是不允许的。

第二个是资金字段不要用 double 或 float。这是 Java 开发中特别经典的坑。浮点数在机器内部是二进制表示的,计算 0.1 + 0.2 都可能出现精度误差。金额字段在数据库里建议用decimal(10,2)类型,Java 程序里用BigDecimal来处理。MyBatis-Plus 里对这个类型的映射自动支持,不需要额外配置,但你在做运算或者手动赋值时要注意不要用普通double运算。

2.3 订单号生成与状态流转

订单号生成是每个电商类项目都躲不开的点,无人超市也不例外。最简单粗暴的是用当前时间戳,但并发情况下容易撞号。我建议用“时间戳 + 随机数 + 用户ID后缀”的组合方式,示例代码:

public static String generateOrderNo(Long userId) { // 格式:yyyyMMddHHmmss + 3位随机数 + 用户ID后4位 SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String timePart = sdf.format(new Date()); int randomPart = (int) ((Math.random() * 9 + 1) * 100); String userPart = String.format("%04d", userId % 10000); return timePart + randomPart + userPart; }

订单状态我一般用整数状态机管理:0-待支付,1-已支付,2-已取消,3-已完成,4-退款中,5-已退款。用整数存数据库,用常量类定义名称。不要用字符串直接存“未支付”“已支付”这种中文值,因为一旦需求方改个叫法,你就要全表 UPDATE,而用数字常量只改一处定义即可。

状态流转要注意“作废/取消”操作的时机。例如待支付的订单用户自己取消,或者超时未支付由定时任务自动取消,取消后必须回补商品库存。这块逻辑如果不做,就会出现“用户下单没付款,库存却一直锁着”的问题。

3. 实操过程与核心环节实现

3.1 环境准备与项目初始化

开始之前,先确认本地环境:JDK 1.8 或 11、Maven 3.6+、MySQL 5.7+、IDEA。Spring Boot 版本我建议选 2.7.x(最后一个支持 JDK8 的稳定版本),不要一上来就上 Spring Boot 3,因为 3 里面强制要求 JDK17,且部分老版本依赖不兼容,容易给自己找麻烦。

创建项目有两种方式:一种是去 Spring Initializr 网站生成基础骨架,另一种是直接用 IDEA 自带的 Spring Initializr。我习惯在 Initializr 网页上选好依赖下载下来,再用 IDEA 打开,顺便指定好 Maven 仓库地址。常用的依赖就四个:Spring Web、MyBatis Framework、MySQL Driver、Lombok。其他像 Sa-Token、Knife4j、Redis 之类的依赖,直接写在 pom.xml 里引入就行。

pom.xml 里几个关键依赖的引入方式:

<!-- MyBatis-Plus 代码生成与基础 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <!-- Sa-Token 权限认证框架 --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot-starter</artifactId> <version>1.37.0</version> </dependency> <!-- Knife4j 接口文档 --> <dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-openapi2-spring-boot-starter</artifactId> <version>4.4.0</version> </dependency>

application.yml 里的关键配置:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/unmanned_supermarket?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

两个细节值得注意。第一,map-underscore-to-camel-case一定要设为 true,这样数据库里的create_time才能自动映射到 Java 属性里的createTime,否则你就要在每个实体类上写一堆@TableField注解。第二,logic-delete-field是 MyBatis-Plus 的全局逻辑删除配置,只要实体类里有deleted字段,删除操作就自动变成 UPDATE,而不是物理 DELETE。逻辑删除的好处是保留历史数据,方便追溯和统计。

3.2 购物车与自助结算流程的代码实现

购物车是无人超市顾客端的核心功能。这里我采用的方案是:购物车数据存 Redis,结算时同步一份快照到订单明细表。用 Redis 的好处是访问速度快、天然支持过期时间,顾客在自助收银台前操作时体验更流畅。当然,如果你们实际项目中 Redis 用的不够熟练,也可以直接存在数据库表里,用user_id作为用户购物车标识,效果差距不大,关键是逻辑要跑通。

购物车操作的核心接口

@RestController @RequestMapping("/api/cart") public class CartController { @PostMapping("/add") public Result addToCart(@RequestBody CartItemDTO dto) { // 1.校验商品是否存在以及是否上架 Product product = productService.getById(dto.getProductId()); if (product == null || product.getStatus() != 1) { return Result.fail("商品不存在或已下架"); } // 2.校验库存是否充足 if (product.getStock() < dto.getQuantity()) { return Result.fail("库存不足"); } // 3.写入Redis购物车,key为 cart:userId ,使用Hash结构 cartService.addToCart(getLoginUserId(), dto); return Result.success(); } @GetMapping("/list") public Result cartList() { List<CartItemVO> list = cartService.getCartList(getLoginUserId()); return Result.success(list); } @DeleteMapping("/remove/{productId}") public Result removeCartItem(@PathVariable Long productId) { cartService.removeFromCart(getLoginUserId(), productId); return Result.success(); } }

这里的getLoginUserId()是通过 Sa-Token 拦截器里已经写入当前登录用户上下文的方法获取的,不用每次从 token 里手动解析。

结算与下单的核心代码

@Transactional(rollbackFor = Exception.class) public OrderVO checkout(Long userId) { // 1.获取用户购物车列表 List<CartItem> cartItems = cartService.getCartList(userId); if (cartItems.isEmpty()) { throw new BizException("购物车为空,无法下单"); } // 2.计算总金额,校验库存(采用悲观锁或乐观锁机制,避免超卖) BigDecimal totalAmount = new BigDecimal("0"); for (CartItem item : cartItems) { Product product = productService.getByIdWithLock(item.getProductId()); if (product.getStock() < item.getQuantity()) { throw new BizException("商品「" + product.getName() + "」库存不足"); } BigDecimal subtotal = product.getPrice().multiply(new BigDecimal(item.getQuantity())); totalAmount = totalAmount.add(subtotal); } // 3.生成订单主表记录 OrderInfo order = new OrderInfo(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setPayAmount(totalAmount); order.setStatus(0); // 待支付 orderInfoMapper.insert(order); // 4.生成订单明细快照(注意保存当时的商品名称和价格) // 5.执行库存扣减(这里用 UPDATE ... SET stock = stock - #{quantity} 的原子写) // 6.清空购物车 cartService.clearCart(userId); return OrderVO.from(order); }

这里有一个非常关键的细节:扣减库存不能先查后减,否则在并发场景下会出现超卖。正确做法是直接用 SQL 语句做原子扣减:

@Update("UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}") int decrStock(@Param("productId") Long productId, @Param("quantity") Integer quantity);

如果返回的影响行数为 0,说明库存已经不够了,这时候再抛异常提示用户。这个写法是解决高并发超卖问题的经典方案,面试官问起并发问题时你也能主动说出这个方案。

3.3 数据看板与销售统计

数据看板是很多同学喜欢最后才做,甚至干脆不做的模块。但实际上它是最能体现“系统价值”的功能,毕竟无人超市运营方最关心的就是卖了多少、赚了多少、什么东西好卖。

数据统计模块的核心就是几条 SQL:

今日销售额

SELECT IFNULL(SUM(pay_amount), 0) AS amount FROM order_info WHERE status = 1 AND DATE(create_time) = CURDATE()

热销商品 TOP 10

SELECT product_name, SUM(quantity) AS total_sales FROM order_item GROUP BY product_id ORDER BY total_sales DESC LIMIT 10

近7日订单趋势

SELECT DATE(create_time) AS day, COUNT(*) AS order_count, SUM(pay_amount) AS amount FROM order_info WHERE status = 1 AND create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(create_time) ORDER BY day

如果在答辩时想加分,可以借助 ECharts 把这些数据渲染成折线图和柱状图。后端返回 JSON 数组,前端直接绑定数据到图表实例即可,效果非常直观。

4. 常见问题与排查技巧实录

这套系统写下来,绝大多数人会踩的坑基本就在下面这几个地方。我把它们整理出来,并给出了排查思路和解决方案,希望你能提前避开,而不是周末熬夜查 bug。

4.1 事务不生效,库存扣减异常

这是我在批改代码时见最多的问题。@Transactional注解默认只能拦截 RuntimeException 异常,而且必须是自调用之外的方法调用。如果你在同一个类里用this.checkout()调用自身,事务是不会生效的;如果你在方法里用 try-catch 把异常吞掉了,事务也不会回滚。

解决方式有两种:

  1. 把事务方法放到另一个 Service 中,通过 Spring 容器注入调用,例如在OrderServiceImpl里调用CartServiceImpl的回滚方法。
  2. 在@Transactional里显式指定回滚类型:@Transactional(rollbackFor = Exception.class),并且不要让异常被调用方吞掉。

不管哪种方式,都要清楚一点:事务要生效,前提是这个调用链必须经过 Spring 代理。自己调用自己,代理就失效了。

4.2 前后端联调时的跨域问题

如果你用的前端是 Vue,开发时前端在 8081 端口,后端在 8080 端口,那么你调接口一定会遇到跨域问题。解决方式是在后端做一个全局 CORS 配置类:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

如果配置了 Sa-Token 的拦截器,需要先放行预检请求(OPTIONS),否则同样会跨域失败。这里注意 Sa-Token 的注册顺序要在 CORS 配置之后。

4.3 数据库时间与本地时间相差 8 小时

这是又一个经典问题。原因是数据库连接的serverTimezone没有设置成Asia/Shanghai,导致 JDBC 驱动用 UTC 时间解析。解决方式有两个:一是在连接串里加上serverTimezone=Asia/Shanghai;二是在 Spring Boot 中配置spring.jackson.time-zone=GMT+8。如果你实体类里的时间字段用了LocalDateTime,建议搭配@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")注解来统一格式化。

4.4 图片上传后前端显示不出来

商品模块通常要支持上传图片,很多同学用相对路径把图片存在本地目录,结果前端访问时 404。建议的做法是:在配置文件中自定义一个上传路径映射:

@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceLocations("file:" + uploadDir + "/"); } }

这样前端回显时直接用/images/xxx.jpg访问即可。不要用文件的真实磁盘路径拼到页面上,那是绝对路径,部署到服务器上必挂。

5. 性能优化与后续扩展思路

5.1 大批量查询时的分页与缓存

商品列表和订单列表在数据量上来之后,如果不做分页,数据库压力会非常大。MyBatis-Plus 的Page对象用法很简单:

Page<Product> page = new Page<>(current, size); QueryWrapper<Product> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1) .like(StringUtils.hasText(keyword), "name", keyword) .orderByDesc("create_time"); productService.page(page, wrapper);

这里注意like的第一个参数是 boolean 类型判断条件,只有关键词不为空时才拼接这个条件,能有效避免空值导致 SQL 拼接问题。

热点商品可以用 Redis 做缓存,每天定时刷新一次缓存数据。顾客端首次查询时查库,后续直接走缓存,响应时间能从几百毫秒降到几十毫秒。但要注意一个问题:如果商品修改了价格,缓存里的旧数据会继续存活,需要设置合理的过期时间比如 30 分钟,或者在后台修改商品时主动删除对应缓存 key。

5.2 数据统计怎么应对客户临时加需求

“临时加需求”是项目开发里永远躲不开的事情,无人超市也不例外。我在线上遇到最多的是两类需求:一是“老板想看每个分类的销售占比”,二是“想看回头客的消费频次”。

应对这类需求,我的建议是不要直接写死 SQL 后硬编到代码里,而是把数据看板模块做成“通用查询接口”。你前期设计订单明细表时,如果把category_id和category_name冗余进去,那么后面所有统计都只需要GROUP BY category_id,不用去关联分类表。这就是数据库设计里常说的“查询场景的冗余设计”,牺牲一点存储空间,换来的是查询逻辑极简和灵活性提升。

5.3 后续还能往哪些方向扩展

如果做完基础功能想锦上添花,可以考虑这几个方向:

  • 引入 Redis 验证码:用户注册、登录增加图形验证码或短信验证码(短信需要第三方服务)。
  • 对接支付模拟接口:本地实现一个模拟支付网关,支持余额、积分、优惠券抵扣,演示效果很好。
  • 增加库存预警定时任务:每 10 分钟扫描一次库存低于阈值的商品,通过站内信或邮件提醒运营人员。
  • 多层会员体系与营销活动:满减、折扣、限时秒杀。这些也是很好的面试素材,尤其是限时秒杀可以延伸到 Redis + Lua 或消息队列的话题。

不过我也要提醒一句,扩展功能永远是在基础功能完全跑通、文档写好的前提下才做。很多人贪多嚼不烂,核心流程没做完就急着加秒杀、加分布式锁,最后主线功能一塌糊涂,答辩时连订单状态流转都讲不清楚,得不偿失。

6. 一些做这类项目的总结性建议

根据我个人的实操经验,最后给你几条具体建议。

第一,建表脚本一定要先在本地跑通。很多人不重视这一步,代码写到一半才发现某个字段漏了,再回头改实体类、改 SQL、改前端页面,改到怀疑人生。我习惯的做法是:先建库建表,填入少量测试数据,再用 Navicat 或 DataGrip 把表结构截图保存到项目 docs 目录,这样写文档和答辩展示都很方便。

第二,接口设计从一开始就用统一的返回格式。我常用的结构是{ code: 200, message: "success", data: {...} },写一个 Result 工具类,所有接口都返回这个结构,不要一会返回 Map 一会返回实体类,否则前端联调时会非常痛苦。如果你后端代码比较多,也可以用@RestControllerAdvice做全局异常拦截,统一封装异常信息。

第三,写代码前先画清楚状态流转图。订单状态、库存状态、支付状态这些都涉及多步骤流转,建议先用一张表格整理清楚“谁在什么条件下能触发什么操作”,然后再去写代码。很多逻辑 bug 都是因为状态没理清楚,比如“已支付”的订单能不能被用户删除?“已完成”的订单能不能申请售后?这些规则你作为开发者必须先定好,不能等测试同学来提。

第四,不要忽视单元测试。无需全模块覆盖,但核心的服务方法(下单、库存扣减、金额计算)至少要写几个测试用例。我见过太多人把项目代码写完发现下单后库存没变,最后定位到是 SQL 的问题。有了测试,这类低级错误一跑就现形,不用等部署上去才发现。

这套系统我先后在不同场景下搭过好几轮,每次都会发现新的细节问题,但整体骨架已经验证得非常稳定。希望这篇文章能帮你少走一些弯路,也期待看到你完成自己的版本。如果你在实现过程中有具体问题,欢迎带着报错信息和相关代码来交流,一起把项目打磨得更完善。

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

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

立即咨询