后台权限功能设计与实现

本文档详细描述了如何在基于 Postgres 和 Node.js 的 Nest.js 项目中设计并实现一个高质量的后台权限管理功能。设计包括菜单、按钮、接口级别的权限控制,并提供清晰的实现步骤和代码示例。


功能需求

  1. 用户管理
  • 添加、编辑、删除用户。
  • 分配角色给用户。
  1. 角色管理
  • 创建、编辑、删除角色。
  • 分配权限到角色。
  1. 权限管理
  • 定义菜单、按钮、接口权限。
  1. 权限控制
  • 不同用户根据角色看到不同的菜单和操作。
  • 接口权限校验,防止非法访问。

数据库设计

表结构

  1. users (用户表)
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL, -- 用户名,唯一标识用户
    password VARCHAR(255) NOT NULL,        -- 用户密码,建议加密存储
    role_id INT REFERENCES roles(id) ON DELETE SET NULL, -- 关联角色表,角色被删除时设为空
    created_at TIMESTAMP DEFAULT NOW(),    -- 创建时间,默认为当前时间
    updated_at TIMESTAMP DEFAULT NOW()     -- 更新时间,自动更新
);
  1. roles (角色表)
CREATE TABLE roles (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL,      -- 角色名称,唯一标识角色
    description TEXT,                      -- 角色描述,用于说明角色用途
    created_at TIMESTAMP DEFAULT NOW(),    -- 创建时间,默认为当前时间
    updated_at TIMESTAMP DEFAULT NOW()     -- 更新时间,自动更新
);
  1. permissions (权限表)
CREATE TABLE permissions (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,            -- 权限名称,便于识别
    type VARCHAR(20) CHECK (type IN ('menu', 'button', 'api')) NOT NULL, -- 权限类型,菜单、按钮或接口
    value VARCHAR(255) UNIQUE NOT NULL,    -- 权限值,用于唯一标识权限
    created_at TIMESTAMP DEFAULT NOW(),    -- 创建时间,默认为当前时间
    updated_at TIMESTAMP DEFAULT NOW()     -- 更新时间,自动更新
);
  1. role_permissions (角色权限表)
CREATE TABLE role_permissions (
    id SERIAL PRIMARY KEY,
    role_id INT REFERENCES roles(id) ON DELETE CASCADE, -- 关联角色表,角色删除时级联删除
    permission_id INT REFERENCES permissions(id) ON DELETE CASCADE, -- 关联权限表,权限删除时级联删除
    UNIQUE(role_id, permission_id) -- 确保角色与权限的组合唯一
);

服务端实现

核心模块

用户模块 (UsersModule)

  • 包含用户 CRUD 和角色分配接口。

角色模块 (RolesModule)

  • 提供角色管理和权限分配功能。

权限模块 (PermissionsModule)

  • 负责定义权限,并为角色分配权限。

鉴权模块 (AuthModule)

  • 提供登录、JWT 认证和权限校验中间件。

权限校验中间件

权限校验中间件负责在每个接口请求中校验用户是否具备必要的权限。以下是核心逻辑的详细说明:

从请求中获取用户权限

  • 每个请求通过 Authorization 头部携带 JWT Token。
  • 中间件解析 Token,从中提取用户 ID。
  • 根据用户 ID 查询数据库,获取其权限集合。可通过以下查询实现:
SELECT p.value
FROM permissions p
INNER JOIN role_permissions rp ON p.id = rp.permission_id
INNER JOIN users u ON rp.role_id = u.role_id
WHERE u.id = $1;

缓存机制

  • 为了优化性能,用户权限集合可以存储在缓存中(如 Redis)。
  • 每次查询前检查缓存是否有对应权限数据。
  • 用户权限变更后,可通过事件或手动触发清除缓存。

校验逻辑

  • 通过 Reflector 获取接口所需的权限列表。
  • 检查用户权限集合是否包含所有所需权限。
  • 示例校验代码:
const requiredPermissions = this.reflector.get<string[]>('permissions', context.getHandler());
if (!requiredPermissions) return true;

const hasPermission = requiredPermissions.every(permission => userPermissions.includes(permission));
if (!hasPermission) {
  throw new ForbiddenException('Access denied');
}
return true;

性能优化措施

  • 批量查询:对于批量请求,可一次性查询多个用户权限集合,减少数据库查询次数。
  • 数据结构优化:在缓存中存储权限集合时,采用 Set 数据结构以提高查询速度。
  • 按需加载:对于动态权限检查,只加载当前请求所需的权限,减少数据传输量。

通过以上设计,权限校验中间件不仅确保了安全性,还在高并发场景下提供了良好的性能。

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';

@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(private reflector: Reflector, private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const requiredPermissions = this.reflector.get<string[]>('permissions', context.getHandler());
    if (!requiredPermissions) return true;

    const request = context.switchToHttp().getRequest<Request>();
    const authHeader = request.headers['authorization'];
    if (!authHeader) return false;

    const token = authHeader.split(' ')[1];
    const user = this.jwtService.verify(token);

    // Fetch user's permissions from DB or cache
    const userPermissions = await this.getUserPermissions(user.id);

    return requiredPermissions.every(permission => userPermissions.includes(permission));
  }

  private async getUserPermissions(userId: number): Promise<string[]> {
    // Query the database for the user's permissions
    return ['menu:dashboard', 'button:create-user', 'api:get-users']; // Example
  }
}

接口示例

  1. 获取菜单
@Get('menus')
@UseGuards(PermissionsGuard)
@SetMetadata('permissions', ['menu:dashboard'])
async getMenus() {
  return [
    {
      id: 1,
      name: 'Dashboard',
      path: '/dashboard',
      icon: 'dashboard', // 菜单图标,用于前端显示
      children: [], // 子菜单列表,若为顶级菜单则为空数组
    },
    {
      id: 2,
      name: 'Users',
      path: '/users',
      icon: 'users', // 菜单图标
      children: [
        {
          id: 3,
          name: 'User List',
          path: '/users/list',
          icon: 'list',
        },
        {
          id: 4,
          name: 'User Roles',
          path: '/users/roles',
          icon: 'roles',
        },
      ],
    },
  ];
}
@Get('menus')
@UseGuards(PermissionsGuard)
@SetMetadata('permissions', ['menu:dashboard'])
async getMenus() {
  return [
    { id: 1, name: 'Dashboard', path: '/dashboard' },
    { id: 2, name: 'Users', path: '/users' },
  ];
}
  1. 创建用户
@Post('users')
@UseGuards(PermissionsGuard)
@SetMetadata('permissions', ['button:create-user'])
async createUser(@Body() createUserDto: CreateUserDto) {
  return this.userService.create(createUserDto);
}

前端实现

菜单动态渲染

基于用户权限动态加载菜单

  1. 用户登录成功后,前端通过 API 获取用户权限列表及其对应的菜单数据。
  2. 后端返回的菜单数据包含菜单层级关系(父子关系)、路径(path)、显示名称(name)、图标(icon)等字段。
  3. 前端根据用户权限动态生成菜单树,示例代码如下:
const renderMenu = (permissions, allMenus) => {
  const buildMenuTree = (menuList, parentId = null) => {
    return menuList
      .filter(menu => menu.parentId === parentId && permissions.includes(menu.permission))
      .map(menu => ({
        ...menu,
        children: buildMenuTree(menuList, menu.id),
      }));
  };

  return buildMenuTree(allMenus);
};

const userPermissions = ['menu:dashboard', 'menu:users'];
const allMenus = [
  { id: 1, name: 'Dashboard', path: '/dashboard', icon: 'dashboard', permission: 'menu:dashboard', parentId: null },
  { id: 2, name: 'Users', path: '/users', icon: 'users', permission: 'menu:users', parentId: null },
  { id: 3, name: 'User List', path: '/users/list', icon: 'list', permission: 'menu:user:list', parentId: 2 },
  { id: 4, name: 'User Roles', path: '/users/roles', icon: 'roles', permission: 'menu:user:roles', parentId: 2 },
];

const menuTree = renderMenu(userPermissions, allMenus);
console.log(menuTree);

缓存菜单数据

  • 使用 LocalStorage 或 SessionStorage 缓存菜单数据,减少重复请求:
const fetchMenus = async () => {
  const cachedMenus = localStorage.getItem('menus');
  if (cachedMenus) {
    return JSON.parse(cachedMenus);
  }
  const menus = await api.getMenus();
  localStorage.setItem('menus', JSON.stringify(menus));
  return menus;
};

权限变更处理策略

  • 实时更新:在后台修改用户权限后,通过事件通知或 WebSocket 推送通知前端清理缓存并重新拉取最新数据。
  • 定期校验:前端定期校验权限有效性,例如在用户长时间未操作时,重新校验权限。

通过以上逻辑,前端可以根据动态加载的菜单数据和权限设置灵活地渲染用户界面,并在权限变更时保持数据一致性和安全性。

  • 基于用户权限,动态加载菜单。
  • 示例:
const renderMenu = (permissions) => {
  const allMenus = [
    { id: 1, name: 'Dashboard', path: '/dashboard', permission: 'menu:dashboard' },
    { id: 2, name: 'Users', path: '/users', permission: 'menu:users' },
  ];

  return allMenus.filter(menu => permissions.includes(menu.permission));
};

按钮权限控制

  • 使用自定义指令控制按钮显示。
Vue.directive('permission', {
  inserted(el, binding, vnode) {
    const userPermissions = vnode.context.$store.state.permissions;
    if (!userPermissions.includes(binding.value)) {
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
});

接口权限封装

  • Axios 请求拦截器:
axios.interceptors.response.use(null, (error) => {
  if (error.response.status === 403) {
    alert('Permission denied');
  }
  return Promise.reject(error);
});

总结

本设计实现了一个高效且安全的权限管理系统,涵盖了从数据库到前后端完整的技术细节。通过角色和权限的组合,系统可以灵活地适配不同业务需求,同时确保权限校验的准确性和安全性。

实际应用场景

  • 电商平台后台:为商家、管理员和客服人员设置不同的权限。例如,商家可以管理自己的店铺和订单,管理员可以管理全平台的用户和数据。
  • 企业内部系统:在 HR 系统中,不同部门的员工访问不同的模块,例如招聘模块、绩效考核模块。
  • 教育平台:教师可以管理课程和学生数据,学生只能访问自己的课程信息。

可能遇到的挑战

权限粒度过细

  • 问题:粒度过细可能导致权限管理的复杂性增加,难以维护。
  • 解决方案:使用分层权限设计,将低级权限绑定到角色,通过角色实现权限分配。

高并发下的性能瓶颈

  • 问题:频繁查询权限可能导致数据库压力过大。
  • 解决方案
  • 使用缓存(如 Redis)存储用户权限集合。
  • 在缓存失效时分批异步加载权限。

权限变更同步问题

  • 问题:权限更新后,客户端无法及时获知变化。
  • 解决方案:通过 WebSocket 或事件通知机制实时推送权限变更,或定期刷新权限数据。

通过以上分析和优化策略,权限管理系统能够在复杂业务场景下保持高效和灵活性,同时提供良好的用户体验。