第 9 章:菜单模块
学习目标
- 实现菜单的 CRUD + 树形结构
- 实现"当前用户菜单"接口(根据权限过滤)
- 区分目录、菜单、按钮三种类型
一、菜单实体
java
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_menu")
public class Menu extends BaseEntity {
private Long parentId;
private String name;
private Integer type; // 1=目录 2=菜单 3=按钮
private String permission; // type=3 才有,如 "user:create"
private String path; // 路由路径
private String component; // 前端组件
private String icon;
private Integer sort;
private Integer visible; // 0=隐藏 1=显示
private Integer status;
}三种 type 的视觉差别:
| type | 含义 | 示例 |
|---|---|---|
| 1 | 目录 | "系统管理",左侧一级 |
| 2 | 菜单 | "用户管理",可点开 |
| 3 | 按钮 | 页内按钮"新增用户" |
二、菜单树 VO
java
@Data
public class MenuVO {
private Long id;
private Long parentId;
private String name;
private Integer type;
private String permission;
private String path;
private String component;
private String icon;
private Integer sort;
private Integer visible;
private List<MenuVO> children; // 子菜单,递归结构
}⚠️ 坑 1:菜单用树形 VO,但数据传输给前端时,子节点超过 3 层记得加分页或懒加载。几千个菜单一次性返回会让前端渲染卡顿。
三、Service
java
public interface MenuService extends IService<Menu> {
Long createMenu(MenuCreateDTO dto);
void updateMenu(Long id, MenuUpdateDTO dto);
void deleteMenu(Long id);
// 全量菜单树(管理用)
List<MenuVO> tree();
// 当前用户的菜单树
List<MenuVO> currentUserMenuTree();
// 当前用户的所有按钮权限
Set<String> currentUserPermissions();
}java
@Service
@RequiredArgsConstructor
public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements MenuService {
private final RoleMenuMapper roleMenuMapper;
private final PermissionService permissionService;
private final MenuConvert menuConvert;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createMenu(MenuCreateDTO dto) {
Menu menu = menuConvert.toEntity(dto);
menu.setStatus(1);
baseMapper.insert(menu);
return menu.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateMenu(Long id, MenuUpdateDTO dto) {
Menu menu = baseMapper.selectById(id);
Assert.notNull(menu, "菜单不存在");
menuConvert.updateEntity(dto, menu);
baseMapper.updateById(menu);
// 菜单改动影响所有用户权限
permissionService.clearAllCache();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteMenu(Long id) {
// ① 检查子菜单
Long childCount = baseMapper.selectCount(
new LambdaQueryWrapper<Menu>().eq(Menu::getParentId, id));
if (childCount > 0) {
throw new BusinessException("请先删除子菜单");
}
// ② 检查是否被角色引用
Long refCount = roleMenuMapper.countByMenuId(id);
if (refCount > 0) {
throw new BusinessException("菜单已被角色使用,无法删除");
}
baseMapper.deleteById(id);
permissionService.clearAllCache();
}
@Override
public List<MenuVO> tree() {
List<Menu> all = baseMapper.selectList(
new LambdaQueryWrapper<Menu>().orderByAsc(Menu::getSort));
return buildTree(all, 0L);
}
@Override
public List<MenuVO> currentUserMenuTree() {
Long userId = SecurityUtils.getCurrentUserId();
List<Menu> menus = baseMapper.selectByUserId(userId); // SQL JOIN 过滤
return buildTree(menus, 0L);
}
@Override
public Set<String> currentUserPermissions() {
return permissionService.getUserPermissions(SecurityUtils.getCurrentUserId());
}
/**
* 递归构造树
*/
private List<MenuVO> buildTree(List<Menu> all, Long parentId) {
return all.stream()
.filter(m -> m.getParentId().equals(parentId))
.map(m -> {
MenuVO vo = menuConvert.toVO(m);
vo.setChildren(buildTree(all, m.getId()));
return vo;
})
.sorted(Comparator.comparing(MenuVO::getSort))
.toList();
}
}四、Mapper 自定义 SQL
currentUserMenuTree 需要按用户权限过滤,自定义 SQL:
xml
<select id="selectByUserId" resultType="com.taskflow.system.menu.entity.Menu">
SELECT DISTINCT m.* FROM sys_menu m
INNER JOIN sys_role_menu rm ON m.id = rm.menu_id
INNER JOIN sys_user_role ur ON rm.role_id = ur.role_id
WHERE ur.user_id = #{userId}
AND m.is_deleted = 0
AND m.status = 1
AND m.type IN (1, 2) <!-- 只返回目录和菜单,不返回按钮 -->
ORDER BY m.sort ASC
</select>⚠️ 坑 2:
type IN (1, 2)是关键。按钮(type=3)不应该出现在菜单树里,否则左侧导航会冒出乱七八糟的"用户新增"按钮。
五、Controller
java
@RestController
@RequestMapping("/api/menu")
@RequiredArgsConstructor
@Tag(name = "菜单管理")
public class MenuController {
private final MenuService menuService;
@GetMapping("/tree")
@Operation(summary = "全量菜单树(管理)")
public Result<List<MenuVO>> tree() {
return Result.ok(menuService.tree());
}
@GetMapping("/current")
@Operation(summary = "当前用户的菜单树")
public Result<List<MenuVO>> currentUserTree() {
return Result.ok(menuService.currentUserMenuTree());
}
@GetMapping("/permissions")
@Operation(summary = "当前用户按钮权限")
public Result<Set<String>> permissions() {
return Result.ok(menuService.currentUserPermissions());
}
@PostMapping
@PreAuthorize("hasAuthority('menu:create')")
public Result<Long> create(@RequestBody @Valid MenuCreateDTO dto) {
return Result.ok(menuService.createMenu(dto));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('menu:update')")
public Result<Void> update(@PathVariable Long id,
@RequestBody MenuUpdateDTO dto) {
menuService.updateMenu(id, dto);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('menu:delete')")
public Result<Void> delete(@PathVariable Long id) {
menuService.deleteMenu(id);
return Result.ok();
}
}六、PermissionService 完整实现
java
@Service
@RequiredArgsConstructor
public class PermissionService {
private final RedisTemplate<String, Object> redis;
private final RoleMapper roleMapper;
private final MenuMapper menuMapper;
private static final String PERM_KEY_PREFIX = "user:perms:";
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
public Set<String> getUserPermissions(Long userId) {
String key = PERM_KEY_PREFIX + userId;
// ① 查缓存
Object cached = redis.opsForValue().get(key);
if (cached instanceof Collection<?> coll) {
return coll.stream().map(Object::toString).collect(Collectors.toSet());
}
// ② 查 DB
Set<String> perms = new HashSet<>();
List<Role> roles = roleMapper.selectByUserId(userId);
for (Role role : roles) {
List<Menu> menus = menuMapper.selectByRoleId(role.getId());
for (Menu menu : menus) {
if (Integer.valueOf(3).equals(menu.getType()) && StringUtils.hasText(menu.getPermission())) {
perms.add(menu.getPermission());
}
}
}
// ③ 写缓存
if (!perms.isEmpty()) {
redis.opsForValue().set(key, perms, CACHE_TTL);
}
return perms;
}
public void clearCache(Long userId) {
redis.delete(PERM_KEY_PREFIX + userId);
}
public void clearAllCache() {
Set<String> keys = redis.keys(PERM_KEY_PREFIX + "*");
if (keys != null && !keys.isEmpty()) {
redis.delete(keys);
}
}
}七、前端动态路由(Vue)
登录后,根据 /api/menu/current 返回的菜单树动态生成路由:
javascript
// src/router/dynamic.js
import { defineAsyncComponent } from 'vue'
const modules = import.meta.glob('@/views/**/*.vue')
export function buildRoutes(menuTree) {
return menuTree.map(menu => {
const route = {
path: menu.path,
name: menu.name,
meta: { title: menu.name, icon: menu.icon }
}
if (menu.component) {
// 根据 component 路径动态加载
const componentPath = `/src/views/${menu.component}.vue`
if (modules[componentPath]) {
route.component = modules[componentPath]
}
}
if (menu.children?.length) {
route.children = buildRoutes(menu.children)
}
return route
})
}javascript
// 在 Pinia 的 user store 里
export const useUserStore = defineStore('user', {
state: () => ({
menus: [],
permissions: new Set()
}),
actions: {
async loadMenus() {
const { data } = await api.getCurrentMenus()
this.menus = data
this.permissions = new Set(data.flatMap(m => extractPerms(m)))
},
hasPermission(perm) {
return this.permissions.has(perm)
}
}
})vue
<!-- 用 v-permission 控制按钮显示 -->
<el-button v-if="userStore.hasPermission('user:create')" @click="create">
新增用户
</el-button>⚠️ 坑 3:前端隐藏按钮只是 UX,后端必须再校验一次!绕过前端直接调 API 的攻击 90% 都靠这个。
@PreAuthorize是最后一道防线,绝对不能省。
八、菜单图标
Element Plus 用 Icon 组件:
vue
<el-icon><component :is="menu.icon" /></el-icon>或者直接:
vue
<el-icon>
<component :is="menu.icon || 'Menu'" />
</el-icon>九、本章小结
| 要点 | 关键 |
|---|---|
| 三种 type | 1=目录 2=菜单 3=按钮 |
| 树形构建 | 递归 + parentId |
| 用户菜单 | JOIN 过滤,不含 type=3 |
| 缓存 | Redis 30 分钟,菜单改动清所有 |
| 前端 | 动态路由 + v-permission |
| 按钮权限 | type=3 + permission 字段 |
动手练习
- 菜单树:写两个接口:
/api/menu/tree(全量) 和/api/menu/current(当前用户) - 按钮校验:把
/api/user/create加@PreAuthorize,普通用户测被拦截 - 缓存清理:改菜单后,普通用户权限立即生效(测试登出再登录 vs 立即生效)
下一章:第 10 章:操作日志 →