无人超市这个概念喊了好几年真正动手把它落地成一套可运行的完整系统时才发现里面的细节远比想象中多。一个人脸识别的门禁、一个自动结算的购物车、一个能管商品和订单的后台单独拆开都不算难难的是把这一整套串成完整闭环的交易链路还要保证数据一致、能并发、能顺利部署。这套前后端分离的无人智慧超市管理系统我前后重构了两版最终用 SpringBoot Vue MyBatis MySQL 定型。这篇文章不是放个项目截图就完事而是把业务拆解、表结构设计、并发扣库存、前端状态同步、以及从源码到部署的完整过程都梳理出来适合有 JavaWeb 基础、想拿完整项目练手或准备相关方向面试的朋友。1. 无人零售对整套系统的硬性要求以及我为什么押注SpringBootVue先看无人智慧超市管理系统到底要解决什么问题。表面上是店里面没有人看着实际上是三个问题谁进了店、拿了什么、该收多少钱。这三个问题映射到系统里就是用户管理、商品管理与库存管理、订单结算管理。再往后还得有后台管理界面让运营者能看销售额、库存预警、用户行为记录。这套业务模型看着普通但真正选型的时候有几个坑会反复纠结。我最终敲定的技术组合是 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 iddeductStock UPDATE product SET stock stock - #{quantity}, sales sales #{quantity}, version version 1 WHERE id #{productId} AND stock gt; #{quantity} AND version #{version} /update如果在高并发测试环境中发现这次更新返回的影响行数为 0说明库存不足或版本冲突订单事务整体回滚前端收到失败提示。这套方案比SELECT ... FOR UPDATE的悲观锁性能更好也不会长时间锁行对无人超市这种读多写少的场景非常合适。实际联调时我还发现一个细节在循环里逐条扣减多个商品库存时必须使用同一数据库连接的同一事务。否则第一个商品扣成功、第二个商品扣失败会出现一个订单对应部分扣库存的脏数据。解决方式很简单把所有扣减方法都放在Transactional标注的服务方法内MyBatis 执行的 SQL 会共享同一个事务连接。3.3 ResultMap关联映射避免N1查询的常见配置订单列表页需要展示订单同时带出商品明细最常见的坑是 N1 查询先查 10 条订单再循环 10 次查明细。数据库压力翻倍页面响应自然变慢。MyBatis 解决这个问题的方案是 ResultMap 嵌套集合映射resultMap idOrderDetailMap typeOrderVO id propertyid columnid / result propertyorderNo columnorder_no / result propertytotalAmount columntotal_amount / collection propertyitems ofTypeOrderItemVO columnid selectcom.example.mapper.OrderItemMapper.selectByOrderId/ /resultMap这种写法用一条订单查询加延迟加载明细比一条巨长的 join 更直观。但要特别注意collection 里的column必须传订单表的主键否则子查询拿到的 id 是错的。我踩过这个坑最后确认columnid映射的是父查询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 秒拦截统一返回结构只处理业务码例如code200表示成功code401表示 token 过期需要重新入场。统一返回结构是前后端联调时最容易出问题的地方。我的约定是{ code: 200, message: success, data: {} }前端拿到响应后先判断code而不是 HTTP 状态码。因为很多情况下后端会返回 HTTP 200 但业务码是非 200比如库存不足这种业务失败。如果不统一处理前端每个页面都要写一遍判断后期维护非常痛苦。5. 完整部署记录前后端分离项目的启动顺序与踩坑验证源码能跑起来是一回事能在生产环境顺利部署是另一回事。这一章我把从克隆项目到浏览器访问的完整流程写出来版本搭配和各类坑都附上。5.1 环境版本搭配JDK、Node、数据库版本的兼容性一个常见问题是网上源码用了较新的语法但本地 JDK 版本太老编译直接报错。我这套系统的推荐版本是JDK 1.8 或 11后端基于 SpringBoot 2.xMaven 3.6Node.js 14 或 16Vue 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 连接串上加allowPublicKeyRetrievaltrueuseSSLfalse。用 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 版本会直接语法报错。第三个坑是时区问题。数据库连接串如果没加serverTimezoneAsia/ShanghaiMySQL 8 默认时区可能与本地相差 8 小时订单创建时间会显示错误做统计报表时数据对不上。这个坑非常隐蔽因为页面一般只显示日期少数细心的人会发现下午的单子变成了凌晨。我个人在实际操作中的体会是部署阶段出的大部分问题都不是代码逻辑问题而是环境差异和配置细节。所以强烈建议照着上面的版本搭配来不要看到新版本就手痒升级——SpringBoot 2.x JDK 8 Vue 2 这套组合经过大量项目验证稳定压倒一切。如果后续想继续扩展这个项目可以考虑接入真正的人脸识别服务端把特征向量检索替换成更成熟的算法也可以在订单结算后增加小票打印功能对接店内蓝牙打印机还可以把后台的统计报表做成可视化图表直观展示销售额随时间的波动。从一套课程级源码变成能真正放回店里运行的系统这个距离并没有想象中远多推进一步收获就多一分。