Skip to content
第 9 章 ⏱ 10 分钟阅读

第 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>

九、本章小结 ​

要点关键
三种 type1=目录 2=菜单 3=按钮
树形构建递归 + parentId
用户菜单JOIN 过滤,不含 type=3
缓存Redis 30 分钟,菜单改动清所有
前端动态路由 + v-permission
按钮权限type=3 + permission 字段

动手练习 ​

  1. 菜单树:写两个接口:/api/menu/tree(全量) 和 /api/menu/current(当前用户)
  2. 按钮校验:把 /api/user/create 加 @PreAuthorize,普通用户测被拦截
  3. 缓存清理:改菜单后,普通用户权限立即生效(测试登出再登录 vs 立即生效)

下一章:第 10 章:操作日志 →

本站基于 VitePress 构建 · 由 StackHub 团队维护