无人超市这个概念喊了好几年,真正动手把它落地成一套可运行的完整系统时,才发现里面的细节远比想象中多。一个人脸识别的门禁、一个自动结算的购物车、一个能管商品和订单的后台,单独拆开都不算难,难的是把这一整套串成完整闭环的交易链路,还要保证数据一致、能并发、能顺利部署。这套前后端分离的无人智慧超市管理系统,我前后重构了两版,最终用 SpringBoot + Vue + MyBatis + MySQL 定型。这篇文章不是放个项目截图就完事,而是把业务拆解、表结构设计、并发扣库存、前端状态同步、以及从源码到部署的完整过程都梳理出来,适合有 JavaWeb 基础、想拿完整项目练手或准备相关方向面试的朋友。
1. 无人零售对整套系统的硬性要求,以及我为什么押注SpringBoot+Vue
先看无人智慧超市管理系统到底要解决什么问题。表面上是"店里面没有人看着",实际上是三个问题:谁进了店、拿了什么、该收多少钱。这三个问题映射到系统里,就是用户管理、商品管理与库存管理、订单结算管理。再往后还得有后台管理界面,让运营者能看销售额、库存预警、用户行为记录。
这套业务模型看着普通,但真正选型的时候有几个坑会反复纠结。我最终敲定的技术组合是 SpringBoot + Vue + MyBatis + MySQL,前后端完全分离。下面把每个选择的理由摊开来说。
1.1 无人超市真正要盯住的核心问题不是"无人"
很多人一上来就想着人脸识别、自动称重、视觉商品识别这些花哨功能。但作为系统开发,第一优先级其实是不漏单、不重复扣款、结算可追溯。
无人超市和传统超市最大的差别在于:传统超市收银员是结算的强校验节点,而无人超市把这个节点拆成了系统里的多个动作。用户进门、选品、离店结算,每一步都对应后端接口的调用和状态变更。如果链路中间任何一步断了——比如用户网络波动、前端页面闪退、接口超时重试——就可能出现"东西带走了但钱没扣"或者"扣了两次款"的情况。
所以我在设计初就立了一条原则:前端只负责"展示意图",后端负责"执行事实"。购物车里的价格、数量只是给用户看的预期值,真正影响库存和订单的,永远是后端接口在事务里完成的数据库操作。这个原则贯穿了整篇文章,很多代码细节都是围绕它展开的。
1.2 前后端分离架构与无人零售场景的天然匹配
选前后端分离不只是因为"流行",是因为无人超市的场景天然需要多端接入:店内的自助结算大屏、用户手机上的小程序、管理员的网页后台,它们都要访问同一套业务接口。如果做成单体页面,后面每加一个客户端都得重新处理模板渲染,维护成本会快速膨胀。
用 SpringBoot 提供纯 RESTful API,用 Vue 搭前端界面,两者通过 JSON 通信,各自独立部署,互不干扰。这样的好处有三点:
- 店内设备的页面更新不影响后端服务,升级前端只用重新打包部署静态资源。
- 管理员后台和用户结算端可以各自独立开发,只要接口约定好。
- 权限边界清晰,后端通过 token 识别用户身份,前端只按角色展示对应页面。
这套结构在真实项目里我也实践过多次,稳定性和协作效率都比较满意。
1.3 MyBatis取代JPA的原因:对SQL细节的掌握需求
有人会问,SpringBoot 官方推荐组合里 Spring Data JPA 也很方便,为什么这里偏偏选 MyBatis?我的答案很直接:无人超市的库存扣减和订单统计,需要你对 SQL 有精确控制力。
JPA 在简单 CRUD 上很舒服,但一旦涉及动态条件查询、多表关联、行锁控制,要么写 JPQL,要么碰原生 SQL,反而多了一层抽象。MyBatis 则把 SQL 完全暴露给你,写 UPDATE 的时候可以精准控制 where 条件,写报表查询的时候可以自由 join。
举个例子,扣库存时必须保证"库存足够才扣",这条 SQL 在 MyBatis 里可以这样写:
UPDATE product SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{productId} AND stock >= #{quantity} AND version = #{version}如果是 JPA,这种带有版本号与条件防护的更新就得走 @Modifying 原生 SQL,反而绕路。加上 MyBatis 的<foreach>动态 SQL 在小票订单批量插入时非常顺手,所以最终定下来 SpringBoot + MyBatis 的组合。
2. 从进门到自动扣款:交易链路里三个必须死磕的状态
交易链路是整个系统的心脏。我把它拆成了三个阶段:鉴权入场、挑选商品、离店结算。每个阶段都对应一个"状态",而状态之间的流转必须严密,否则就会出现楼上提到的漏单或重复扣款。
2.1 鉴权入场:用户身份的签发、过期与识别
无人超市的进门方式我采用的是"人脸识别 + 用户编码绑定"。用户第一次使用需要在小程序或前台注册,录入姓名、手机号,并做人脸特征绑定。系统里并不存储人脸原始照片,只保存特征向量,出店结算时通过特征向量找到对应用户,这也符合当前对个人信息保护的基本要求。
进门时前端调用后端入口接口:
POST /api/auth/enter 参数:faceToken(人脸识别终端返回的特征标识)后端拿到 faceToken 后去用户表检索,校验状态是否正常、是否有未完成订单,然后签发一个短时有效的入场会话 token。这个 token 存在 Redis 里(如果不想引 Redis,也可以存在数据库的一张会话表,设置过期时间),用户选品期间前端每次请求都携带它。
这里有个小设计值得注意:入场 token 和账号登录 token 要分开。账号登录 token 时效可以很长,入场 token 则以本次进店为生命周期。离店结算完成后,入场 token 立即失效。这样即使有人拿着别人的登录 token,也无法随意为对方的账户下单。
2.2 挑选商品:购物车与库存数据的一致性问题
用户进店后,在自助大屏上扫码或点选商品加入购物车。这个动作看起来很简单,但前后端各有一套"购物车":前端有 Vue 实例里的购物车状态,后端有订单草稿或购物车表。
我的方案是:前端购物车负责交互展示,后端购物车负责数据校验。用户每添加一件商品,前端先本地更新数量用于界面反馈,同时向后端发送一条请求,后端在事务里重新校验商品上架状态和库存余量,返回最终可用数量。这样前端显示的数量永远小于等于后端真实可卖数量。
如果多人同时抢购同一件商品,前端的"加法"是不可信的,必须以后端的原子更新为准。这也是为什么我在商品表里加了stock字段,并且所有扣减都用条件 UPDATE 而不是先 SELECT 再 UPDATE。
2.3 离店结算:订单生成与支付结果的最终一致性
用户拿着商品走到结算门,触发离店结算。前端把购物车明细提交给后端,后端做三件事:
- 再次校验所有商品的状态和库存;
- 在一个数据库事务里生成订单主表和订单明细表;
- 扣减库存,累加对应商品的销量字段。
订单状态我设计了四档:待支付、已支付、已取消、已退款。无人超市常见的简化方案是"结算即支付成功",把支付服务对接成模拟接口甚至直接置为已支付。但为了让系统有真实的可用性,我保留了独立的支付单流程:订单生成后先落到"待支付",前端弹出结算二维码或调用模拟支付接口,支付回调成功后把订单置为"已支付"。
这里的关键是幂等性。支付回调可能因为网络原因被推送多次,后端处理逻辑必须保证同一个订单只能被置为已支付一次。我的做法是在订单表上加pay_status字段,并在更新语句里加上条件:
UPDATE orders SET pay_status = 2 WHERE id = #{orderId} AND pay_status = 1根据更新行数判断是否成功。如果返回 0,说明已被处理过,直接忽略该次回调。
3. 数据库表设计与MyBatis映射中容易翻车的几处细节
数据库设计是这套系统里最"枯燥"但最值得写的部分。无外乎四张核心表:用户表、商品表、订单主表、订单明细表,外加几张辅助表如会话记录、支付流水。字段不多,但每个细节都可能在后期反噬项目。
3.1 四张核心业务表:字段设计要能支撑后续统计
我贴一下核心建表 SQL,注意事项写在注释之后:
CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, phone VARCHAR(20), face_feature VARCHAR(512) COMMENT '人脸特征向量', balance DECIMAL(10,2) DEFAULT 0.00, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_no VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, sales INT NOT NULL DEFAULT 0, status TINYINT DEFAULT 1, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, pay_status TINYINT DEFAULT 1, order_status TINYINT DEFAULT 1, pay_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(128), price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL );订单明细里冗余了product_name和price,这是有意为之。因为商品价格和名称后续可能调整,订单作为历史凭证必须保留下单那一刻的快照。这个设计在做财务对账时能省掉大量麻烦。
3.2 扣减库存的并发控制:乐观锁与UPDATE语句的配合
无人超市的促销场景里,最怕的就是超卖。高并发下多个用户同时结算同一款商品,如果代码写成"先查库存,够就扣",那在查到与扣到之间的时间窗口里,库存可能已经被别人改掉。
我使用的是乐观锁方案。每次更新库存前检查版本号,版本一致才更新成功:
<update id="deductStock"> UPDATE product SET stock = stock - #{quantity}, sales = sales + #{quantity}, version = version + 1 WHERE id = #{productId} AND stock >= #{quantity} AND version = #{version} </update>如果在高并发测试环境中发现这次更新返回的影响行数为 0,说明库存不足或版本冲突,订单事务整体回滚,前端收到失败提示。这套方案比SELECT ... FOR UPDATE的悲观锁性能更好,也不会长时间锁行,对无人超市这种读多写少的场景非常合适。
实际联调时我还发现一个细节:在循环里逐条扣减多个商品库存时,必须使用同一数据库连接的同一事务。否则第一个商品扣成功、第二个商品扣失败,会出现一个订单对应部分扣库存的脏数据。解决方式很简单,把所有扣减方法都放在@Transactional标注的服务方法内,MyBatis 执行的 SQL 会共享同一个事务连接。
3.3 ResultMap关联映射:避免N+1查询的常见配置
订单列表页需要展示订单同时带出商品明细,最常见的坑是 N+1 查询:先查 10 条订单,再循环 10 次查明细。数据库压力翻倍,页面响应自然变慢。
MyBatis 解决这个问题的方案是 ResultMap 嵌套集合映射:
<resultMap id="OrderDetailMap" type="OrderVO"> <id property="id" column="id" /> <result property="orderNo" column="order_no" /> <result property="totalAmount" column="total_amount" /> <collection property="items" ofType="OrderItemVO" column="id" select="com.example.mapper.OrderItemMapper.selectByOrderId"/> </resultMap>这种写法用一条订单查询加延迟加载明细,比一条巨长的 join 更直观。但要特别注意:collection 里的column必须传订单表的主键,否则子查询拿到的 id 是错的。我踩过这个坑,最后确认column="id"映射的是父查询orders.id,而不是别的同名字段。
更彻底的做法是直接用一条 SQL 做连表查询,配合ofType加resultMap的嵌套结果映射,只是 XML 配置会更长。对中小型系统,上面这种延迟加载方式已经够用。
4. Vue前端不是摆样式:页面状态与后端数据如何保持一致
很多项目源码里,Vue 部分就是几个静态页面加一点假数据,跑起来好看但一接后端就崩。我在重构第二版时,重点就是让前端的页面状态与后端数据严格对应,别看只是购物车、结算页、后台列表,实际操作里的坑一个不少。
4.1 Token的存储与携带:前端鉴权的最小实现
身份认证我用的是最简单的 token 方案:登录或入场后,后端返回 token,前端存到localStorage。Axios 请求拦截器里统一加上请求头:
axios.interceptors.request.use(config => { const token = localStorage.getItem('access_token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })注意这里不要用 sessionStorage,因为页面一旦刷新 sessionStorage 里的 token 会丢失,用户正在选购的商品就前功尽弃了。用 localStorage 虽然理论上有 XSS 风险,但配合后端对关键接口做二次校验,实际项目里完全够用。
路由守卫也要同步做判断:像结算页、后台管理页这类需要登录的角色页面,在路由跳转前检查 token 是否存在,不存在就强制回到入口页。这一步避免了很多"直接输入 URL 进后台"的尴尬情况。
4.2 购物车状态管理:从Vuex到Pinia的选择
第一版我用 Vuex,第二版换成了 Pinia。核心并不是哪个更好,而是要有一个统一的状态管理容器来承载购物车和用户信息。如果没有这个容器,购物车数据散落在组件 props 里,一个商品加购后,另一个页面无法感知,结算时还得重新拉取,体验很差。
Pinia 的 store 设计得更干净,写购物车模块大致是这样:
export const useCartStore = defineStore('cart', { state: () => ({ items: [], sessionToken: '' }), actions: { async addItem(product) { const res = await api.addToCart(this.sessionToken, product.id, 1) if (res.data.success) { this.items.push({ productId: product.id, quantity: 1, price: res.data.price }) } else { // 后端库存不足或商品下架 } } } })这套逻辑的关键是:每次 addItem 都会请求后端校验,后端返回的 price 才是用户最终会支付的单价。前端页面里展示的product.price只用于商圈浏览时的预估,真正进到购物车明细后,价格以接口返回为准。
还有一个细节:购物车里修改数量时,要防止用户疯狂点击导致连续发送多个并发请求。我在 action 里加了一个lock标志,请求未返回时新的添加会被忽略,同时按钮统一 loading。这个小优化在高并发演示环境里尤其重要。
4.3 前端调后端时的异常兜底:超时、重试与提示
无人超市的终端设备网络环境不一定稳定,特别是店内大屏可能走无线网络。前端调用后端接口如果超时,不能简单抛个错误就完事,需要做一层兜底。
Axios 的封装里我统一做了三件事:
- 设置合理的超时时间,比如 10 秒,超过后提示用户网络异常;
- 对查询类接口做一次自动重试,间隔 1 秒;
- 拦截统一返回结构,只处理业务码,例如
code=200表示成功,code=401表示 token 过期需要重新入场。
统一返回结构是前后端联调时最容易出问题的地方。我的约定是:
{ "code": 200, "message": "success", "data": {} }前端拿到响应后,先判断code而不是 HTTP 状态码。因为很多情况下后端会返回 HTTP 200 但业务码是非 200,比如"库存不足"这种业务失败。如果不统一处理,前端每个页面都要写一遍判断,后期维护非常痛苦。
5. 完整部署记录:前后端分离项目的启动顺序与踩坑验证
源码能跑起来是一回事,能在生产环境顺利部署是另一回事。这一章我把从克隆项目到浏览器访问的完整流程写出来,版本搭配和各类坑都附上。
5.1 环境版本搭配:JDK、Node、数据库版本的兼容性
一个常见问题是网上源码用了较新的语法,但本地 JDK 版本太老,编译直接报错。我这套系统的推荐版本是:
- JDK 1.8 或 11(后端基于 SpringBoot 2.x)
- Maven 3.6+
- Node.js 14 或 16(Vue CLI 5 比较稳)
- MySQL 5.7 或 8.0
特别注意 MySQL 8.0 的驱动问题。SpringBoot 2.x 默认引入的驱动是com.mysql.cj.jdbc.Driver,如果连接参数格式不对,启动时会报Public Key Retrieval is not allowed。解决方式是在 JDBC 连接串上加allowPublicKeyRetrieval=true&useSSL=false。用 MySQL 5.7 则相对省事,驱动类用com.mysql.jdbc.Driver也行,但建议统一用新驱动。
5.2 从源码到系统可访问:配置、打包与启动
项目里配置文件主要改两个地方:application.yml的数据库连接信息,和前端utils/request.js里的 API 基础路径。
后端启动命令:
mvn clean package -DskipTests java -jar supermarket-system.jar启动后后端默认跑在localhost:8080。验证方式很简单,浏览器访问后端接口的健康检查地址,如果能返回 JSON,说明后端已经在工作了。
前端打包:
npm install npm run build打包产物在dist目录。如果本地开发调试,直接npm run serve起前端开发服务器,通过代理转发后端接口。
生产环境我一般把dist目录放到 Nginx 下,同时用 Nginx 做反向代理:
server { listen 80; server_name your-domain; location / { root /var/www/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个配置里最容易被忽略的是try_files $uri $uri/ /index.html。没有这一行,Vue Router 用 history 模式时,用户刷新非首页会直接 404。
5.3 上线前的冒烟验证清单:从数据库到页面
部署完不能只看首页能打开就宣布成功。我做了一套冒烟测试清单,建议你也照着走一遍:
- 注册一个新用户,确认数据能写入 user 表;
- 用该用户发起入场鉴权,拿到入场 token;
- 在商品管理后台新增一件测试商品,确认前台能立即看到;
- 将商品加入购物车,数量改为 2;
- 模拟并发下单,观察库存是否正确扣减,订单金额是否等于单价乘以数量;
- 检查数据库中 user 表、orders 表、order_item 表的数据一致性;
- 刷新前端页面,确认 token 依然有效,购物车状态没有丢失。
这七步只要全部通过,系统基本可以放心对外开放。我见过不少项目能跑起来但金额对不上,问题多半出在第三步到第五步之间。
5.4 部署中常见的三个坑及排查思路
最后集中说一下我部署过程中亲手踩过的三个坑。
第一个坑是跨域问题。开发环境前端端口和后端端口不同,浏览器会发出跨域请求。如果后端没开 CORS,前端控制台报错,接口全部失败。解决方式是在后端加全局跨域配置,或者开发环境的代理指向后端。生产环境用 Nginx 反向代理后,同域部署就不存在这个问题。
第二个坑是数据库初始化顺序。有人图省事直接把项目里所有 SQL 脚本一次性执行,结果因为外键依赖顺序不对报错。正确做法是严格按 user → product → orders → order_item 的顺序建表。另外注意orders是 MySQL 的保留字,建表时要用反引号括起来,否则某些 MySQL 版本会直接语法报错。
第三个坑是时区问题。数据库连接串如果没加serverTimezone=Asia/Shanghai,MySQL 8 默认时区可能与本地相差 8 小时,订单创建时间会显示错误,做统计报表时数据对不上。这个坑非常隐蔽,因为页面一般只显示日期,少数细心的人会发现下午的单子变成了凌晨。
我个人在实际操作中的体会是:部署阶段出的大部分问题都不是代码逻辑问题,而是环境差异和配置细节。所以强烈建议照着上面的版本搭配来,不要看到新版本就手痒升级——SpringBoot 2.x + JDK 8 + Vue 2 这套组合经过大量项目验证,稳定压倒一切。
如果后续想继续扩展这个项目,可以考虑接入真正的人脸识别服务端,把特征向量检索替换成更成熟的算法;也可以在订单结算后增加小票打印功能,对接店内蓝牙打印机;还可以把后台的统计报表做成可视化图表,直观展示销售额随时间的波动。从一套课程级源码变成能真正放回店里运行的系统,这个距离并没有想象中远,多推进一步,收获就多一分。