SpringBoot+Vue超市管理系统:架构、库存事务与部署避坑 简介面向计算机相关专业毕业设计与课程实训场景这套基于SpringBootVue的超市管理系统提供前后端分离的完整项目参考适合需要完成零售管理类选题、积累Java全栈开发经验的学生与初级开发者。压缩包为rar格式共2000个文件、约23.17MB以JavaScript、CSS、Java、Vue组件和XML配置为主辅以JSON、HTML、SQL脚本及少量说明文档基本覆盖前端页面、后端接口、配置与数据库初始化等环节。已有87人学习下载。项目围绕用户权限、商品库存、销售事务、报表统计和系统设置等模块展开读者可据此理解SpringBoot自动配置、Vue组件化开发与前后端联调思路并借助SQL脚本完成数据库结构搭建其目录组织与页面组件拆分方式亦可为同类项目提供借鉴便于按模块定位代码并开展二次开发作为毕业设计原型或功能扩展基础均具参考价值。1. 从一份跑不起来的毕业设计说起这套超市管理系统到底管什么前阵子帮人看一份毕业设计答辩前两天项目在自己电脑上跑不起来——不是代码写错是建表脚本里的字符集和本地 MySQL 对不上导入一半就报错。这套基于 SpringBoot Vue 的超市管理系统是典型的毕业设计选题但它的骨架子其实比标题看起来扎实前后端完全分离后端 RESTful 接口前端 SPA 单页业务覆盖商品与分类、供应商、进货入库、销售出库、库存流水、会员、员工与角色权限后台首页还有 ECharts 销售的统计图。适合两类人一类是要在短时间内把一套完整业务跑通、能讲清楚架构的毕设同学另一类是刚学完 SpringBoot 想找个有真实业务闭环下单会扣库存、扣库存要有流水的项目练手的开发者。下面按“它怎么设计 → 代码长什么样 → 怎么跑起来 → 怎么改”的顺序拆。2. 技术选型与分层架构前后端分离在这类系统里怎么落地2.1 技术栈清单与版本搭配上的取舍这套系统的选型非常“保守”但对一个需要交付、需要别人能照着跑的项目来说保守就是优点。整理成表大致是这样层次选型在项目里的作用后端框架SpringBoot 2.7.x内嵌 Tomcatjar 直接跑不折腾外置容器持久层MyBatis-Plus 3.5.x单表 CRUD 免写 XML复杂报表才手写 SQL数据库MySQL 8.0utf8mb4商品名、供应商名常带生僻字和符号鉴权JWT HandlerInterceptor无状态前端存 token后端不存 session前端框架Vue 2.6 Element UI表格、表单、弹窗组件开箱即用请求库axios统一加 token、统一处理 401路由与状态Vue Routerhistory 模式 Vuex菜单权限、登录态图表ECharts 5首页销售趋势、品类占比构建Maven webpack后端打 jar前端打 dist这里有两个取舍值得说。第一是持久层用 MyBatis-Plus 而不是 JPA超市系统里“商品列表按分类 关键字 库存区间 分页”这种动态条件查询极多用QueryWrapper拼条件比写 JPQL 直观而且分页插件PaginationInnerInterceptor一行配置就够不需要自己算limit offset。第二是鉴权用拦截器而不是 Spring SecuritySpring Security 的过滤器链对新手来说是个黑匣子一旦配错就是全站 401 或者全站放行而一个HandlerInterceptor里十几行代码就能把“取 token → 校验 → 塞用户信息 → 放行或返回 401”讲得明明白白答辩时也讲得清楚。常见的做法是密码用 MD5 加盐存储如果想把安全做扎实一点换成 BCrypt 也只是改一个PasswordEncoder的事。2.2 一次请求的完整链路从 Vue 页面到 MyBatis 再回到表格理解这套系统最快的方式是跟着一次“查询商品列表”的请求走一遍。浏览器里点开商品管理页created钩子调getGoodsList(query)axios 实例在请求拦截器里往 header 塞Authorization: Bearer xxx请求打到/api/goods/page。请求先到 Nginx开发阶段是 vue-cli 的 devServer 代理转发到 SpringBoot 的DispatcherServlet。在进入 Controller 之前JwtInterceptor会拦下来白名单登录、验证码、静态资源直接放行其余请求解析 token把userId、username塞进ThreadLocal里的UserContext方便 Service 层随时取当前操作人用于写库存流水的operator字段。校验通过后进入GoodsControllerController 只做参数接收和Valid校验真正的组装逻辑在GoodsServiceImpl先用QueryWrapper拼条件再调goodsMapper.selectPage(page, wrapper)MyBatis-Plus 的分页插件在这里把selectPage翻译成带LIMIT的 SQL 并额外发一条count。结果回到 Controller被包成统一的ResultT——code、msg、data三个字段前端响应拦截器只认code 200。统一返回体和全局异常处理是这套代码里最值得抄的部分。所有 Controller 返回Result.success(data)所有业务错误抛BizException由RestControllerAdvice统一捕获并转成Result.fail(msg)。这样前端永远只需要处理一种响应结构不用为每个接口写一套 if-else。参数校验失败的MethodArgumentNotValidException也在这个 advice 里处理把第一条校验消息返回给前端弹 toast。2.3 数据库表设计商品、库存、订单三张主表怎么串业务表大概十来张真正需要看懂的是四张goods商品、stock_record库存流水、sale_ordersale_order_item销售单与明细。权限相关的sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu是标准的 RBAC 五表结构固定不用花太多心思。核心表的关键字段大致这样建CREATE TABLE goods ( id BIGINT NOT NULL AUTO_INCREMENT, goods_name VARCHAR(120) NOT NULL COMMENT 商品名, category_id BIGINT NOT NULL COMMENT 分类id, supplier_id BIGINT DEFAULT NULL COMMENT 默认供应商, bar_code VARCHAR(64) DEFAULT NULL COMMENT 条形码扫码枪用, purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 进价, sale_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 售价, stock INT NOT NULL DEFAULT 0 COMMENT 当前库存只由流水驱动变更, stock_warn INT NOT NULL DEFAULT 10 COMMENT 库存预警阈值, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (id), UNIQUE KEY uk_bar_code (bar_code), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; CREATE TABLE stock_record ( id BIGINT NOT NULL AUTO_INCREMENT, goods_id BIGINT NOT NULL, change_type TINYINT NOT NULL COMMENT 1入库 -1出库, change_qty INT NOT NULL COMMENT 变动数量恒为正, before_stock INT NOT NULL COMMENT 变动前库存对账用, after_stock INT NOT NULL COMMENT 变动后库存, biz_no VARCHAR(32) NOT NULL COMMENT 关联单号, operator VARCHAR(64) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_goods_time (goods_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;设计上有一条硬规则goods.stock是冗余字段任何一次变更都必须在同一个事务里同时写stock_record。这么做的好处是库存对不上时能查流水定位到是哪一单、谁操作的坏处是写放大但对于超市这种量级完全不是问题。before_stock和after_stock两个字段看着冗余实际排障时救命——一眼就能看出是不是有并发请求插在中间。销售单采用“主表 明细表”结构sale_order存单号、会员、应收、实收、支付方式、操作人sale_order_item存商品、数量、单价、小计单号用yyyyMMdd 6 位序列生成并加唯一索引避免重复提交产生两张单。3. 核心代码拆解登录鉴权、库存扣减与前端请求封装3.1 后端目录结构与几个关键类后端包结构是标准的四层com.xxx.supermarket下面分controller、service接口 impl、mapper、entity、dto、vo、common、config、interceptor、util。新手最容易犯的错是把 DTO 和 Entity 混用这套代码分得比较清楚GoodsDTO接前端传参带NotNull之类的校验注解GoodsVO返回给前端把categoryId换成categoryName、把status换成中文Entity 只和数据库打交道。多一层转换代码但接口文档清晰改字段时不会牵一发动全身。common包里两个类值得单独看ResultT和GlobalExceptionHandler。前者定义了code/msg/data和success()、fail()静态方法后者用RestControllerAdvice捕获BizException、MethodArgumentNotValidException、Exception三类最后那个兜底会把堆栈打到日志里但只给前端返回“系统繁忙”避免把 SQL 报错直接吐到页面上。3.2 登录鉴权JWT 拦截器的最小闭环登录接口要做三件事校验密码、签发 token、查出该用户的角色和菜单权限一起返回前端拿去做动态路由。PostMapping(/login) public ResultLoginVO login(RequestBody Valid LoginDTO dto) { // 1. 查用户。查不到和密码错都返回同一句提示避免被枚举用户名 SysUser user userService.lambdaQuery() .eq(SysUser::getUsername, dto.getUsername()) .one(); if (user null || !PasswordUtil.matches(dto.getPassword(), user.getSalt(), user.getPassword())) { throw new BizException(用户名或密码错误); } if (user.getStatus() 0) { throw new BizException(账号已被停用请联系管理员); } // 2. 签 tokenpayload 只放 uid 和 uname别把权限塞进去 String token JwtUtil.createToken(user.getId(), user.getUsername()); // 3. 权限信息单独查返回给前端做菜单渲染和后端做按钮级校验 LoginVO vo new LoginVO(); vo.setToken(token); vo.setUserInfo(UserConverter.toVO(user)); vo.setMenus(menuService.listMenusByUserId(user.getId())); return Result.success(vo); }JwtUtil里EXPIRE一般设 2 小时SECRET从application.yml读不要硬编码在类里——这一点在答辩时被问到的概率不低。token 的 payload 里只放uid和uname权限每次都查库或用缓存因为把角色写进 token 后管理员改了权限得等 token 过期才生效这类系统里经常被吐槽。拦截器是这套鉴权的另一半public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { // 预检请求直接放行否则浏览器跨域时第一个 OPTIONS 就被打回 401 if (HttpMethod.OPTIONS.matches(req.getMethod())) { return true; } if (!(handler instanceof HandlerMethod)) { return true; } String token req.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } if (!StringUtils.hasText(token)) { ResponseUtil.writeJson(resp, 401, 未登录或登录已过期); return false; } try { Claims claims JwtUtil.parse(token); UserContext.set(Long.valueOf(claims.get(uid).toString()), claims.get(uname).toString()); return true; } catch (ExpiredJwtException e) { ResponseUtil.writeJson(resp, 401, 登录已过期请重新登录); return false; } catch (Exception e) { ResponseUtil.writeJson(resp, 401, 登录凭证无效); return false; } } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { // ThreadLocal 必须清理Tomcat 线程池复用会导致上一个用户的信息串到下一个人 UserContext.clear(); } }UserContext就是一个ThreadLocalLoginUser的包装。afterCompletion里的clear()是血泪教训——不写这行压测时会出现 A 用户的操作记录写成 B 用户而且极难复现。3.3 出库单提交库存扣减的事务边界超市系统里最容易出问题的就是库存。前端提交一张销售单后端要做的事按顺序是生成单号 → 插主表 → 循环插明细 → 逐个扣库存 → 每个商品写一条流水。这一串必须在同一个事务里中间任何一步失败都要整体回滚不能出现“单子建了库存没扣”或者“库存扣了单子没落库”。Service public class SaleOrderServiceImpl implements SaleOrderService { Override Transactional(rollbackFor Exception.class) // 注意是 Exception不是默认的 RuntimeException public String submit(SaleOrderDTO dto) { String orderNo OrderNoGenerator.next(SO); // SO20240512000001 BigDecimal total dto.getItems().stream() .map(i - i.getPrice().multiply(BigDecimal.valueOf(i.getQty()))) .reduce(BigDecimal.ZERO, BigDecimal::add); SaleOrder order new SaleOrder(); order.setOrderNo(orderNo); order.setTotalAmount(total); order.setPayType(dto.getPayType()); order.setOperator(UserContext.get().getUsername()); orderMapper.insert(order); for (SaleOrderItemDTO item : dto.getItems()) { // 条件更新只有库存够才扣得动返回 0 行说明库存不足 int rows goodsMapper.deductStock(item.getGoodsId(), item.getQty()); if (rows 0) { throw new BizException(商品[ item.getGoodsName() ]库存不足请刷新后重试); } Integer after goodsMapper.selectStock(item.getGoodsId()); stockRecordService.saveRecord(item.getGoodsId(), -1, item.getQty(), after item.getQty(), after, orderNo); // 明细最后插保证主表 - 库存 - 明细的顺序和日志一致 saleOrderItemMapper.insert(buildItem(order.getId(), item)); } return orderNo; } }对应的 Mapper 里那句更新是整套代码里最关键的一行 SQLupdate iddeductStock UPDATE goods SET stock stock - #{qty}, update_time NOW() WHERE id #{goodsId} AND stock #{qty} /update参数说明#{qty}是本次出库数量stock #{qty}这个条件是并发安全的保证——两个请求同时进来InnoDB 行锁会把它们串行化后一个拿到锁时重新判断条件不满足就影响 0 行Service 抛业务异常回滚。常见的错误写法是“先select查库存够不够再update扣减”这中间有个窗口期并发下必然超卖。另外Transactional一定要写rollbackFor Exception.class因为默认只回滚RuntimeException如果抛了受检异常事务不会回滚这个坑排查起来非常隐蔽。3.4 前端请求封装与路由守卫前端src/utils/request.js是统一出口所有 api 文件都引用这个 axios 实例import axios from axios import { Message } from element-ui import store from /store import router from /router import { getToken } from /utils/auth const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 开发环境走 devServer 代理生产环境走 nginx timeout: 10000 }) service.interceptors.request.use(config { const token getToken() if (token) { config.headers[Authorization] Bearer token } return config }, error Promise.reject(error)) service.interceptors.response.use(response { const res response.data // 后端统一返回 { code, msg, data }200 之外全部当失败处理 if (res.code ! 200) { Message({ message: res.msg || 请求失败, type: error, duration: 3000 }) if (res.code 401) { store.dispatch(user/resetToken).then(() { router.push({ path: /login, query: { redirect: router.currentRoute.fullPath } }) }) } return Promise.reject(new Error(res.msg || Error)) } return res.data // 直接解包业务代码里拿到的就是 data }, error { Message({ message: error.message, type: error }) return Promise.reject(error) }) export default service这里有两个细节return res.data做了数据解包所以页面里写const list await getGoodsPage(params)拿到的直接就是分页对象不用再.data.data401 时带上了redirect参数登录成功后跳回原页面这个体验细节很多毕设项目都漏了。路由守卫负责挡住没登录就手动敲地址栏的情况router.beforeEach(async (to, from, next) { const hasToken getToken() if (hasToken) { if (to.path /login) { next({ path: / }) // 已登录再访问登录页直接回首页 } else { const hasRoles store.getters.roles store.getters.roles.length 0 if (hasRoles) { next() } else { try { const { menus } await store.dispatch(user/getInfo) const accessRoutes await store.dispatch(permission/generateRoutes, menus) router.addRoutes(accessRoutes) // 动态挂载有权限的菜单 next({ ...to, replace: true }) } catch (e) { await store.dispatch(user/resetToken) next(/login?redirect${to.path}) } } } } else { to.path /login ? next() : next(/login?redirect${to.path}) } })4. 把项目跑起来环境、建库、启动与打包4.1 环境准备与版本对齐需要装的东西不多JDK 8 或 11、Maven 3.6、MySQL 8.0、Node.js 14/16Vue 2 webpack 4 在 Node 17 以上会因为 OpenSSL 报ERR_OSSL_EVP_UNSUPPORTED这是新手最常见的翻车点、一个 IDEA 或 VS Code。版本对不齐是跑不起来的第一大原因建议直接按项目 README 给的版本来别想着“我电脑上正好有个 Node 20”。后端application.yml里要改的只有四处server.port默认 8080、spring.datasource.url、username、password。连接串里记得带上时区和编码参数useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueallowPublicKeyRetrieval对 MySQL 8 的caching_sha2_password认证是必须的不加会报公钥检索失败。4.2 数据库初始化与配置文件修改建库和导入顺序不能乱# 1. 建库字符集必须显式指定否则继承服务器默认值中文商品名会乱码 mysql -uroot -p -e CREATE DATABASE supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入表结构和基础数据注意先结构后数据 mysql -uroot -p supermarket sql/schema.sql mysql -uroot -p supermarket sql/data.sql # 3. 验证能看到 admin 账号和十几张表就对了 mysql -uroot -p supermarket -e SHOW TABLES; SELECT id,username,status FROM sys_user;data.sql里通常预置了admin/123456这个账号和几条商品数据。如果导入时报Unknown collation: utf8mb4_0900_ai_ci说明脚本是在 MySQL 8 上导出、你要导进 MySQL 5.7把脚本里的排序规则批量替换成utf8mb4_general_ci即可。4.3 前后端启动顺序与联调先起后端再起前端因为前端启动时不需要后端但登录时必须有。后端直接跑SupermarketApplication的main方法控制台看到Started ... in x seconds就通了。前端cd supermarket-ui npm install --registryhttps://registry.npmmirror.com npm run devvue.config.js里的devServer.proxy决定了/api打到哪台后端默认是http://localhost:8080改端口时这里和application.yml要同步改否则就是一片 404。联调的第一步不是点菜单是先打开浏览器 Network 面板看登录请求的返回code是不是 200、data.token有没有值。token 有了但后续接口还是 401八成是请求头名字和后端取的不一致Authorizationvstoken。4.4 打包部署到一台普通服务器后端mvn clean package -DskipTests出 jar前端npm run build:prod出dist。Nginx 配置里两件事最关键静态资源和接口代理以及 history 模式的回退。server { listen 80; server_name _; root /opt/supermarket/dist; index index.html; # history 路由刷新 404 的解法 location / { try_files $uri $uri/ /index.html; } # 注意 proxy_pass 末尾有没有斜杠差别很大 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }proxy_pass http://127.0.0.1:8080/;带斜杠会把/api/goods/page改写成/goods/page不带斜杠则原样透传后端接口就得带/api前缀。这两种约定只能选一种前后端要统一否则整个站所有接口全 404。5. 避坑与排查几个我踩过的坑5.1 跨域时第一个 OPTIONS 请求返回 401现象本地联调登录接口能通但所有带自定义 header 的接口在 Network 里先出现一条红色的OPTIONS状态 401紧接着正式请求根本没发出去。原因浏览器的预检请求不带Authorization而拦截器对所有请求一视同仁地校验 token预检直接被拦下浏览器认为跨域失败。解决在preHandle开头判断HttpMethod.OPTIONS.matches(req.getMethod())直接返回true。更彻底的做法是注册一个CorsFilter并设置较高优先级让跨域处理发生在拦截器之前同时在WebMvcConfigurer里配置addCorsMappings两者不要重复配否则会出现Access-Control-Allow-Origin重复响应头浏览器同样会报错。5.2 分页永远返回第一页现象点“下一页”页码变了表格数据没变。原因前后端参数名不一致。前端传的是page和limit后端PageDTO里定义的是pageNum和pageSizeSpring 绑不上就用默认值 1 和 10所以永远第一页。解决全局统一一套命名并在PageDTO上加默认值和Min校验。更省事的做法是后端接PageT时用new Page(dto.getPageNum(), dto.getPageSize())显式构造别依赖自动绑定。排查时打开 MyBatis 的 SQL 日志logging.level.xxx.mapperdebug看一眼实际执行的LIMIT值一眼就能定位。5.3 库存出现负数或者对不上账现象月底盘点发现某个商品库存是 -3或者流水加起来和goods.stock差了几个。原因两处。一是用了“先查后改”的写法并发下超卖二是早期的入库逻辑只改了goods.stock忘了写stock_record导致流水和结存对不上。解决所有扣减统一走条件更新WHERE stock #{qty}返回 0 行就抛异常所有变更库存的地方强制走StockRecordService一个入口方法方法内部同时更新goods.stock和插流水不允许任何地方绕过它直接update goods set stock。事后再加一个对账定时任务每天凌晨比对SUM(change_qty)和goods.stock不一致就告警。5.4 时间字段全部差 8 小时现象下单时间和服务器当前时间相差 8 小时导出的报表按日期统计也全部错位。原因MySQL 服务端时区是 UTC连接串里又没指定serverTimezone驱动按 JVM 默认时区解析。解决连接串加serverTimezoneAsia/ShanghaiMySQL 侧执行SET GLOBAL time_zone 8:00。同时后端返回给前端的 VO 里用JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)固定序列化格式别让前端自己去猜。5.5 打包上线后刷新页面变 404接口也全挂现象本地npm run dev一切正常部署到 Nginx 后首页能打开F5 刷新就 404接口请求返回 404。原因Vue Router 用的是 history 模式/goods/list这种路径在服务器上并不存在真实文件接口 404 则是proxy_pass末尾斜杠导致路径被截断或反过来多了一层/api。解决Nginx 里加try_files $uri $uri/ /index.html;兜底接口前缀在前后端各查一遍VUE_APP_BASE_API的值、vue.config.js的代理规则、Nginx 的location和proxy_pass四处必须能串成一条完整的路径任何一处多一个或少一个斜杠都会导致全站 404。6. 二次开发从“能跑”到“能改”的几个下手点拿到源码之后不建议上来就加功能先把“权限”和“库存”这两条线改成自己可控的形式后面加任何模块都轻松。改造优先级我一般这么排改造点现状建议做法工作量按钮级权限只做了菜单级后端加RequiresPerm(goods:add)注解 AOP 校验前端用v-permission指令隐藏按钮半天库存预警有阈值字段但无提示加一个Scheduled(cron 0 0 8 * * ?)任务每天扫stock stock_warn的商品写消息表两小时报表统计首页只有两张图加一张按日聚合的sale_stat表用定时任务预计算避免每次实时GROUP BY大表一天单据来源只有手工开单加扫码枪支持bar_code字段已有唯一索引前端监听回车即可两小时按钮级权限的改造最能体现“会不会二次开发”。原来的代码是在 Controller 里手写if (!UserContext.get().isAdmin()) throw ...散落各处且容易漏。改成切面之后权限判断和业务代码彻底解耦Aspect Component public class PermissionAspect { Before(annotation(perm)) public void check(JoinPoint point, RequiresPerm perm) { LoginUser user UserContext.get(); // 超级管理员直接放行其余按权限码匹配 if (user.isAdmin()) { return; } SetString perms permissionCache.getPermsByUserId(user.getId()); if (!perms.contains(perm.value())) { throw new BizException(没有操作权限 perm.value()); } } }参数说明perm.value()就是注解上写的权限码需要和sys_menu.perms字段保持一致登录时一起缓存进 Redis 或 Caffeine避免每次请求都查库。前端那边写一个v-permissiongoods:add指令在inserted钩子里判断权限码不在集合里就el.parentNode.removeChild(el)菜单和按钮就都受控了。验证方式也很直接用一个只勾选了“商品查看”的角色登录手工调POST /goods应该返回“没有操作权限”前端页面上那个新增按钮应该根本不存在。两边都过了说明改造闭环。改造过程中我最容易翻车的地方是缓存——权限改完忘了清缓存管理员在后台点了半天保存用另一个账号登录仍然是旧权限看着像代码没生效实际上是缓存没失效。从那以后我每次动权限表都强制在保存接口里加一句按roleId清缓存并且用两个浏览器一个管理员、一个普通账号对一遍再也不敢只测一个账号就收工。希望帮到你。本文还有配套的精品资源点击获取