深入挖掘前端基础服务&中间件设计-权限控制

1,536 阅读6分钟

我正在参加「码上掘金挑战赛」详情请看:码上掘金挑战赛来了!

前言:

我们今天讲解的设计是框架层对于权限管理的包装。

提供给应用层使用。

我之前写过一篇你真的了解项目中的权限控制吗 一文中,也提到过权限相关的,内容主要是针对业务应用的解决方案,牵扯到权限分类,以及开发场景的应用。

可以作为本次的辅助思考。

设计思路:

和上述几个设计大同小异

image.png

系统中,对于菜单的数据,在应用层一般作为路由权限控制以及菜单的显示

但是在实际开发中,肯定会遇到这样的场景:
后端服务器部署没到位,前后端本地联调,没有菜单显示,怎么办

一般菜单数据权限维护,都是在服务器进行统一维护,当开发初期没有用户相关接口的支持,可能会比较麻烦,

我们在应用层加载的时候,创建了静态文件夹,router.dev.js 开发人员可以临时在文件内写菜单和路由,配置好后,登录进入后,会将静态数据服务端数据进行合并操作

/**
 * 功能模块配置说明---此文件只针对开发环境使用---【不要提交】
 * pageId:页面ID
 * pageName: 页面名称
 * alias:页面别名(pageType==privilege生效 按钮---权限别名)
 * parentId:父页面ID
 * parentIds:父页面ID集合(-1,001,001001)
 * pageType:
 *   entry 入口页面(1、2、3级菜单显示)
 *   cover 非入口页面(不显示在菜单的隐藏路由页面--如详情页面) 
 *   portal 门户页面
 *   privilege 按钮
 * path: 路由地址
 * component: 模块文件地址
 * sortNo: 排序--Number
 * systemId: 系统ID
 */
// 静态路由---本地开发测试使用

export default [{
  pageId: '002221',
  pageName: '考试1',
  alias: '课程管理',
  parentId: '-1',
  parentIds: '-1',
  dependencyIds: '-1',
  pageType: 'entry',
  icon: 'icon-picture1',
  path: '/examination',
  component: 'views/examination',
  systemId: "100000001",
  sortNo: '1',
}]

业务应用:

权限的数据以及注册到框架层权限管理器,主要是在业务组件 InitialRequest

import {  BaseStore } from '@basic-library'
import {  cache } from '@basic-utils'
import { queryPageList, queryAccountDetail } from './service'
import staticRouter from "@/router/router.dev";

async function initialization({accountId  }) {
  const [userPrivileges = { returnObj: [] }, userInfo = { returnObj: {} }] = await Promise.all([
    queryPageList(),
    queryAccountDetail({accountId}),
  ]);

  return { userPrivileges, userInfo}
}
let _BASE_DATA_INIT = false
function InitialRequest(isMode) {
    const accountId = cache.getCache('accountId', "local")
    // const userInfo = BaseStore.app.userInfo
    return initialization({accountId})
    .then((res) => {
        const { userPrivileges, userInfo } = res;
        let globalAuth = []
        if(process.env.NODE_ENV === 'development'){
          globalAuth = staticRouter
        }
        const userAuth = userPrivileges.returnObj
        BaseStore.app.updateUserInfo(userInfo.returnObj);
        BaseStore.auth.initAuthData([...userAuth, ...globalAuth]);
        cache.setCache('_AUTH_STORE_',[...userAuth, ...globalAuth],'session')
        _BASE_DATA_INIT = true;
      })
      .catch((e) => console.error('接口初始化失败!', e));
}

export default InitialRequest

请求逻辑,在一个独立函数initialization中,传递用户ID,进行数据请求

  • 菜单数据 queryPageList
  • 用户详情 queryAccountDetail

本地静态菜单数据加载

import staticRouter from "@/router/router.dev";

if(process.env.NODE_ENV === 'development'){ globalAuth = staticRouter }

服务端菜单数据加载

BaseStore.auth.initAuthData([...userAuth, ...globalAuth]);

权限管理器注册完成后,进行本地存储

cache.setCache('_AUTH_STORE_',[...userAuth, ...globalAuth],'session')

存储的目的:

  • 路由加载使用
  • 菜单显示

动态路由注册

/**
* registerDynamicRoutes 注册动态路由
* @param {*} router 路由
* 场景: 刷新页面
*/
function registerDynamicRoutes(router){
    Window._EDU_SYSTEM_ROUTERS = router.getRoutes()
	const _AUTH_STORE = cache.getCache('_AUTH_STORE_','session') || []
	_AUTH_STORE.forEach( menus => {
        const routerName = menus.path?.substr(1)
        if(routerName && !router.hasRoute(routerName)){
            router.addRoute('home',{
                path: menus.path,
                name: routerName,
                meta: {
                  id:  menus.pageId,
                  title: menus.pageName,
                  isAsync: true,
                  icon: menus.icon || 'icon-wenjian'
                },
                component: ()=> import(`@/${menus.component}`)
            })
        }
	})
}

cache.getCache('_AUTH_STORE_','session') 获取存储的菜单数据

菜单显示

/**
 * 左侧导航-加载器
 * @param {*} routerUrl 
 * @returns  1/2/3...级菜单 pageType=1
 */
const getSidebarMenu = (routerUrl) => {
  const item = BaseStore.auth.getInfoByRoute(routerUrl)
  if (!item) {
    console.warn(`进入非入口页面,当前访问模块${routerUrl}未授权,请联系管理员进行授权!`)
    return
  }
  let pageId = ''
  if (item.parentId == '-1') {
    pageId = item.pageId
  } else {
    pageId = item.parentIds.split(',')[1]
  }
  console.debug('查询pageId=' + pageId + "下的页面")
  return BaseStore.auth.getInfoByNotEntryTree(pageId)
}

主要是左侧的菜单加载使用,其中用到了 BaseStore.auth 这部分就是框架层的权限管理器

权限管理器:

业务使用:

//示例为BaseStore方式
import { BaseStore } from '@basic-library';

function MenuView() {
  const {auth} = BaseStore
  const menuList = auth.userMenuList.filter((v) => v.pageType !== 'entry');
  const isBool= auth.getInfoByName(item.id));
}

API

参数说明
userMenuList用户菜单列表
userAuthList用户菜单及功能全列表
userEntryMenuList用户入口页面菜单列表-一般是一级菜单
userTreeMenuList用户菜单列表-Tree结构
isAuth根据别名判断是否有权限
getInfoByCode根据 code 返回当前菜单功能信息
getInfoByNames根据 codes 批量返回当前菜单功能信息
getInfoByRoute根据 路由地址 返回当前菜单功能信息
getInfoByNotEntryTree根据 父页面ID 返回非入口页面的信息--转化为Tree结构

数据结构

  • pageId:页面ID
  • pageName: 页面名称
  • alias:页面别名(按钮---权限别名)
  • parentId:父页面ID
  • parentIds:父页面ID集合(-1,001,001001)
  • pageType:entry入口页面(entry 入口页面) cover 非入口页面(不显示在菜单的隐藏路由页面)...
  • path 路由地址
  • component 模块文件地址
  • sortNo 排序--Number
  • systemId 系统ID

代码实践

index.js

import produce, { enableMapSet } from "immer";
import { formatAuthData, computeTreeList } from "./utils";
import { orderBy } from "lodash";

enableMapSet();
// auth
// parentId为-1时,是一级菜单 pageType entry 入口页面(1、2、3级菜单显示) cover 非入口页面(不显示在菜单的隐藏路由页面) privilege 按钮
const _AUTH_STORE_ = {
  dataMap: new Map(),
  nameToCode: new Map(),
  codeToName: new Map(),
};

let authStore = produce(_AUTH_STORE_, () => {});

window._AUTH_STORE_ = authStore;

class menu {
  constructor() {
    this.updateTime = 0;
  }

  updateDepHolder() {
    this.updateTime = Date.now();
  }

  /**
   * 计算当前路由集合的树结构,过滤操作权限
   */
  get userMenuList() {
    return orderBy(
      Array.from(authStore.dataMap.values()).filter(this.filterMenus),
      ["sortNo"],
      ["asc"]
    );
  }

  get userAuthList() {
    console.debug("权限最近更新时间->", this.updateTime);
    return Array.from(authStore.dataMap.values());
  }

  get userEntryMenuList() {
    const authArr = Array.from(authStore.dataMap.values()).filter(
      this.filterEntryMenus
    );
    return orderBy(authArr, ["sortNo"], ["asc"]);
  }

  get userTreeMenuList() {
    const authArr = Array.from(authStore.dataMap.values()).filter(
      this.filterMenus
    );
    return orderBy(computeTreeList(authArr), ["sortNo"], ["asc"]);
  }

  getMenuListByType(type) {
    const authArr = Array.from(authStore.dataMap.values()).filter(
      (v) => v.pageType == type
    );
    return orderBy(authArr, ["sortNo"], ["asc"]);
  }

  getInfoByName(name) {
    return authStore.dataMap.get(authStore.nameToCode.get(name));
  }

  getInfoByNames(names) {
    return names.map((name) => this.getInfoByName(name));
  }

  getInfoByCode(code) {
    return authStore.dataMap.get(code);
  }

  getInfoByRoute(pathname) {
    let item;
    const authArr = Array.from(authStore.dataMap.values());
    item = authArr.find((v) => v.path === pathname);
    if (!item) {
      item = authArr.find((v) => v.path && v.path.includes(pathname));
    }
    return item;
  }

  getInfoByNotEntryTree(parentId) {
    let authArr = Array.from(authStore.dataMap.values()).filter(
      this.filterNotEntryMenus
    );
    if (parentId) {
      authArr = authArr.filter((v) => {
        return v.parentIds.split(",").find((r) => r == parentId);
      });
    }
    return orderBy(computeTreeList(authArr), ["sortNo"], ["asc"]);
  }

  isAuth(name) {
    return !!this.getInfoByName(name);
  }

  // 过滤页面菜单(包括1/2/3级)--显示
  filterMenus(v) {
    return v.pageType == "entry";
  }
  // 过滤非1级菜单---(2/3级等)显示
  filterNotEntryMenus(v) {
    return v.parentId != "-1" && v.pageType == "entry";
  }
  // 过滤入口页面菜单---显示--1级菜单
  filterEntryMenus(v) {
    return v.parentId == "-1" && v.pageType == "entry";
  }
  // 过滤操作权限-按钮
  filterPrivilege(v) {
    return v.pageType == "privilege";
  }
  // 过滤非入口页面---叶子菜单下---详情页面等
  filterCover(v) {
    return v.pageType == "cover";
  }
  // 过滤全局路由--用户中心、门户等地址
  filterPortal(v) {
    return v.pageType == "portal";
  }
  /**
   * 操作权限--按钮
   * @param path 当前路径
   * @param alias 权限别名
   *
   */
  getAuthPrivilege(path, alias) {
    const currPageItem = this.getInfoByRoute(path);
    return currPageItem?.alias;
  }

  /**
   * 初始化权限数据
   * @param {*} menus
   * @param {*} privileges
   */
  initAuthData(features = []) {
    authStore = produce(authStore, (draftState) => {
      const { formatMap, nameToCode, codeToName } = formatAuthData(features);
      draftState.dataMap = new Map(formatMap);
      draftState.nameToCode = new Map(nameToCode);
      draftState.codeToName = new Map(codeToName);
    });
    this.updateDepHolder();
  }
}

export { authStore };

export default new menu();

utils.js

主要是权限数据格式转换使用

  • formatAuthData 数据按照ID、名称进行格式化多个集合
  • computeTreeList 扁平集合数据转换Tree树形结构数据
import { cloneDeep } from 'lodash';

export function formatAuthData(features) {
  const formatMap = [];
  const nameToCode = [];
  const codeToName = [];
  features.forEach((feature) => {
    formatMap.push([feature.pageId, feature]);
    nameToCode.push([feature.pageName, feature.pageId]);
    codeToName.push([feature.pageId, feature.pageName]);
  });

  return { formatMap, nameToCode, codeToName };
}

export function computeTreeList(list, id = 'pageId', pid = 'parentId', isNoDeep) {
  let treeData;
  if (!isNoDeep) {
    treeData = cloneDeep(list);
  } else {
    treeData = list;
  }
  let treeMap = {};

  treeData.forEach((v) => {
    treeMap[v[id]] = v;
  });

  let arr = [];
  for (let i = 0, l = treeData.length; i < l; i++) {
    const item = treeData[i];
    let hasParent = false;
    if (item[pid] && treeMap[item[pid]]) {
      hasParent = true;
      const item2 = treeMap[item[pid]];
      !Array.isArray(item2.children) && (item2.children = []);
      item2.children.push(item);
    }
    !hasParent && arr.push(i);
  }
  treeMap = null;
  return treeData.filter((_, index) => arr.includes(index));
}

核心代码解读

1、菜单数据的装置,对List集合数据,按照code、name进行集合拆分,方便语法传参提取
2、提供了基本的菜单数据获取,比如全部菜单数据以及树形数据格式、入口页面菜单集合、根据code或者name获取菜单信息

设计的目的,从系统层考虑,减少业务耦合即可。

老铁们,我们一起关注系列设计:

深入挖掘前端基础服务&中间件设计-basic-library
深入挖掘前端基础服务&中间件设计-字典设计
深入挖掘前端基础服务&中间件设计-配置设计
深入挖掘前端基础服务&中间件设计-权限控制
深入挖掘前端基础服务&中间件设计-请求封装