SpringBoot+Vue超市管理系统:架构、库存事务与部署避坑

发布时间:2026/10/30 6:56:31
SpringBoot+Vue超市管理系统:架构、库存事务与部署避坑

简介:面向计算机相关专业毕业设计与课程实训场景,这套基于SpringBoot+Vue的超市管理系统提供前后端分离的完整项目参考,适合需要完成零售管理类选题、积累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内嵌 Tomcat,jar 直接跑,不折腾外置容器
持久层MyBatis-Plus 3.5.x单表 CRUD 免写 XML,复杂报表才手写 SQL
数据库MySQL 8.0(utf8mb4)商品名、供应商名常带生僻字和符号
鉴权JWT + HandlerInterceptor无状态,前端存 token,后端不存 session
前端框架Vue 2.6 + Element UI表格、表单、弹窗组件开箱即用
请求库axios统一加 token、统一处理 401
路由与状态Vue Router(history 模式)+ Vuex菜单权限、登录态
图表ECharts 5首页销售趋势、品类占比
构建Maven + webpack后端打 jar,前端打 dist

这里有两个取舍值得说。第一是持久层用 MyBatis-Plus 而不是 JPA:超市系统里“商品列表按分类 + 关键字 + 库存区间 + 分页”这种动态条件查询极多,用QueryWrapper拼条件比写 JPQL 直观,而且分页插件PaginationInnerInterceptor一行配置就够,不需要自己算limit offset。第二是鉴权用拦截器而不是 Spring Security:Spring 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字段。校验通过后进入GoodsController,Controller 只做参数接收和@Valid校验,真正的组装逻辑在GoodsServiceImpl:先用QueryWrapper拼条件,再调goodsMapper.selectPage(page, wrapper),MyBatis-Plus 的分页插件在这里把selectPage翻译成带LIMIT的 SQL 并额外发一条count。结果回到 Controller,被包成统一的Result<T>——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_order+sale_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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包里两个类值得单独看:Result<T>和GlobalExceptionHandler。前者定义了code/msg/data和success()、fail()静态方法;后者用@RestControllerAdvice捕获BizException、MethodArgumentNotValidException、Exception三类,最后那个兜底会把堆栈打到日志里但只给前端返回“系统繁忙”,避免把 SQL 报错直接吐到页面上。

3.2 登录鉴权:JWT + 拦截器的最小闭环

登录接口要做三件事:校验密码、签发 token、查出该用户的角色和菜单权限一起返回,前端拿去做动态路由。

@PostMapping("/login") public Result<LoginVO> 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. 签 token,payload 只放 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就是一个ThreadLocal<LoginUser>的包装。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 里那句更新是整套代码里最关键的一行 SQL:

<update id="deductStock"> 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.data;401 时带上了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/16(Vue 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。连接串里记得带上时区和编码参数,useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true,allowPublicKeyRetrieval对 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 --registry=https://registry.npmmirror.com npm run dev

vue.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和pageSize,Spring 绑不上就用默认值 1 和 10,所以永远第一页。

解决:全局统一一套命名,并在PageDTO上加默认值和@Min校验。更省事的做法是后端接Page<T>时用new Page<>(dto.getPageNum(), dto.getPageSize())显式构造,别依赖自动绑定。排查时打开 MyBatis 的 SQL 日志(logging.level.xxx.mapper=debug)看一眼实际执行的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 默认时区解析。

解决:连接串加serverTimezone=Asia/Shanghai,MySQL 侧执行SET GLOBAL time_zone = '+8:00'。同时后端返回给前端的 VO 里用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")固定序列化格式,别让前端自己去猜。

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; } Set<String> perms = permissionCache.getPermsByUserId(user.getId()); if (!perms.contains(perm.value())) { throw new BizException("没有操作权限:" + perm.value()); } } }

参数说明:perm.value()就是注解上写的权限码,需要和sys_menu.perms字段保持一致,登录时一起缓存进 Redis 或 Caffeine,避免每次请求都查库。前端那边写一个v-permission="'goods:add'"指令,在inserted钩子里判断权限码不在集合里就el.parentNode.removeChild(el),菜单和按钮就都受控了。

验证方式也很直接:用一个只勾选了“商品查看”的角色登录,手工调POST /goods应该返回“没有操作权限”,前端页面上那个新增按钮应该根本不存在。两边都过了,说明改造闭环。

改造过程中我最容易翻车的地方是缓存——权限改完忘了清缓存,管理员在后台点了半天保存,用另一个账号登录仍然是旧权限,看着像代码没生效,实际上是缓存没失效。从那以后我每次动权限表,都强制在保存接口里加一句按roleId清缓存,并且用两个浏览器(一个管理员、一个普通账号)对一遍,再也不敢只测一个账号就收工。希望帮到你。

本文还有配套的精品资源,点击获取