Aop+自定义注解实现数据字典映射

数据字典

    Web项目开发中,字典表的一般都会存在,主要用来给整个系统提供基础服务。

  比如男女性别的类型可以使用0和1来进行表示,在存储数据和查询数据的时候,就可以使用字典表中的数据进行翻译处理。

   再比如之前做的一个项目中宠物类型包含老虎-1、海豚-2、大象-3、长颈鹿-4等等;做答题处理时的答题类型,比如单选题、多选题、填空题等等;

   比较常用的有用户类型,普通用户还是VIP用户等等;这些类型的数据也可以存储在数据字典中进行统一处理。

数据结构以及业务

举例:

在业务表中使用的是业务目录,而其中的“数据结构”,“数据目录类型”,“数据来源”使用的是字典表中的id,但我们在页面显示的时候,想用字典表中的value值,即我们在库里相应的业务表的外键存字典表的ID,查询的时候返回给前端在字典表中的value字段。

如以下表以及内容的展示:

目录表:

内容: 外键,字典的ID

如图所示:(字典表)需要我们在表中拿出外键所对应的Label

列举方法

   那有什么办法可以让我们查询出key的同时,将value值也查询出来,当时处理问题的时候想了三种解决方法:

1.要么就写sql 匹配。(在SQL查询的时候做字段的匹配,但是增加了SQL的复杂性不易维护)

2.要么就业务处理。得写个工具类(针对当时的情况感觉可以实现,然后查找了一些资料比如,自定义注解啥的)

3.再就是前端调两次接口,查询一次数据,再查询一次字典表做转译。(所涉及到的字段有四五个甚至更多,这是我问的别的同学,他们公司采用的就是这种方法)

根据查找资料以及实际考虑,最终采用了使用自定义注解的方式去做字段映射。

实现步骤:

首先展示项目的目录结构

总共三个,自定义注解,切面,具体实现

一 ,创建自定义注解

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Dict {

    /**
	 * 字典类型,对应数据库的类型,下方展示
	 */
	String dictType();

	/**
	 * 实体类内对应的中文字段名,默认为“当前字段+Text”
	 * <p>
     *  例如当前字段为“type”,则对应中文字段默认为“typeText”
	 * 就是你在实体中想要给某个字段赋值,这个字段就要写成那个字段
	 */
	String dictField() default "";

}

二,创建切面


@Slf4j
@Aspect
@Component
public class BydDaoDictAspect {
//  * `@Pointcut` 注解用于定义一个切点,切点是决定哪些方法应该被通知(advice)的表达式。
//	* `execution(* com.wiseom.asset.manage.service..*.*(..))` 是一个切点表达式,它表示匹配 `com.wiseom.asset.manage.service` 包及其子包下的所有类的所有方法。
//	* `doPointcut()` 方法本身没有执行任何操作,它只是为切点提供了一个标识符。
	@Pointcut("execution(* com.wiseom.asset.manage.service..*.*(..))")
	public void doPointcut() {
	}
//  * `@AfterReturning` 注解表示在匹配的方法成功执行并返回结果后执行通知。
//  * `pointcut = "doPointcut()"` 表示这个通知应用于之前定义的 `doPointcut` 切点。
//	* `returning = "result"` 捕获返回的结果,并将其作为 `result` 参数传递给通知方法。
//	* 在 `doAfterReturning` 方法中,`DictUtils.convertDict(result)` 是将返回的 `result` 对象转换为字典格式。如果在转换过程中发生异常,它会被捕获并记录到日志中。

	@AfterReturning(pointcut = "doPointcut()", returning = "result")
	public void doAfterReturning(JoinPoint pjp, Object result) {
		try {
			DictUtils.convertDict(result);
		} catch (Exception e) {
			log.error(String.valueOf(e));
		}
	}
}

备注:

1:切点在com.wiseom.asset.manage.service的原因,因为我们使用了mybitsplus ,在我们处理查询,或者执行SQL的时候大多都在service层就被处理了,所以切点设在了service层。

2:如果切点设置在Controller,则需要修改下方实现三中的 方法 convertDict,因为 Controller的返回值拿到的是 R 包裹的内容,返回值对象是R,而不是实体,具体问题查看下方的扩展四

三,创建实现


@Component
public class DictUtils {

	private static final String DICT_FIELD_SUFFIX = "Text";

	/**
	 * 统一获取当前实体类涉及到的字典表数据,避免多次查询数据库造成的性能消耗
     * 这个工具类主要的改动就是这个,所以我就把这一段放在上面了
	 * @param dictNames 字典表type值
	 * @return 字典表数据
	 */
	@SneakyThrows
	private static  Map<String, Map<Long, String>> getDictMap(List<String> dictNames) {
		//创建fegin的实例,调用系统的sys_dict_item表,需要新写一个接口
		final 	RemoteDictService remoteDictService = SpringContextHolder.getBean(RemoteDictService.class);
		//把注解里的所有字典的类型查出来,具体根据需求做查询
		List<SysDictItem> dictList = remoteDictService.getDictItemByType(dictNames);
		//根据类型做一个分组,这里对应的是 Map<DictType, Map<ID, Label>>
		return dictList.stream().collect(Collectors.groupingBy(
				SysDictItem::getDictType, Collectors.toMap(SysDictItem::getId, SysDictItem::getLabel, (v1, v2) -> v2)));
	}

	/**
	 * 这是主方法主要是判断AOP后返回的内容,并根据判断条件拿到里面的内容
     *  然后先判断有哪些带有注解,根据注解拿到对应的dictType去库里查找对应的内容 
     *  根据拿到内容在对指定的字段dictField进行赋值
	 */
	public static void convertDict(Object target) {
		//判断对象是不是分页的
		if (target instanceof Page) {
             //拿到分页中的records对象里边包含实体的内容
			for(Object object : ((Page<?>)target).getRecords()){
				//拿到实体
				List<DictDefinition> dictDefinitions = getMetadata(object);
				// 如果没有注解,则直接返回
				if (CollectionUtils.isEmpty(dictDefinitions)) return;
				// 从字典定义中提取所有的注解的 dictType(数据库字典的类型)
				List<String> dictNames = dictDefinitions.stream().map(d -> d.getDict().dictType()).collect(Collectors.toList());
				// 根据字典类型获取字典映射
				Map<String, Map<Long, String>> dictMapMap = getDictMap(dictNames);
				// 转换target对象的字典字段
				doConvertDict(object, dictDefinitions, dictMapMap);
			}
		} else
		if (target instanceof List) {
			// 将target强制转换为List<?>类型
			List<?> objectList = ((List<?>) target);

			// 使用CollectionUtils.isNotEmpty来检查列表是否非空
			if (CollectionUtils.isNotEmpty(objectList)) {
				// 获取列表中第一个元素的字典定义
				List<DictDefinition> dictDefinitions = getMetadata(objectList.get(0));
				// 如果没有注解,则直接返回
				if (CollectionUtils.isEmpty(dictDefinitions)) return;
				// 从字典定义中提取所有的注解的 dictType(数据库字典的类型)
				List<String> dictNames = dictDefinitions.stream().map(d -> d.getDict().dictType()).collect(Collectors.toList());
				// 根据字典类型获取字典映射
				Map<String, Map<Long, String>> dictMapMap = getDictMap(dictNames);
				// 遍历列表中的每个元素,并转换其字典字段
				objectList.forEach(t -> doConvertDict(t, dictDefinitions, dictMapMap));
			}
		} else {
			// 如果target不是List,则直接获取其字典定义
			List<DictDefinition> dictDefinitions = getMetadata(target);
			// 如果没有注解,则直接返回
			if (CollectionUtils.isEmpty(dictDefinitions)) return;
			// 从字典定义中提取所有的注解的 dictType(数据库字典的类型)
			List<String> dictNames = dictDefinitions.stream().map(d -> d.getDict().dictType()).collect(Collectors.toList());
			// 根据字典类型获取字典映射
			Map<String, Map<Long, String>> dictMapMap = getDictMap(dictNames);
			// 转换target对象的字典字段
			doConvertDict(target, dictDefinitions, dictMapMap);
		}
	}

	/**
	 * 仅获取一次Dict元数据,降低多次反射造成的性能消耗
	 * @param target 目标实体类
	 * @return Dict元数据
	 */
	private static List<DictDefinition> getMetadata(Object target) {
		//这一段是判断存在注解的字段
		List<DictDefinition> dictDefinitions = new ArrayList<>();
		if (ClassUtils.isPrimitiveOrWrapper(target.getClass())
				|| target instanceof Map || target instanceof String) {
			return dictDefinitions;
		}
		List<Field> fields = FieldUtils.getAllFieldsList(target.getClass());
		for (Field field : fields) {
			Dict dict = AnnotationUtils.getAnnotation(field, Dict.class);
			if (dict != null) {
				DictDefinition dictDefinition = new DictDefinition();
				dictDefinition.setDict(dict);
				dictDefinition.setField(field);
				dictDefinitions.add(dictDefinition);
			}
		}
		return dictDefinitions;
	}

	@SneakyThrows
	private static void doConvertDict(Object target, List<DictDefinition> dictDefinitions,
									  Map<String, Map<Long, String>> dictMapMap) {
		for (DictDefinition dictDefinition : dictDefinitions) {
            //获取Dict注解和字段信息
			Dict dict = dictDefinition.getDict();
			Field field = dictDefinition.getField();
			//获取字典映射
			Map<Long, String> dictMap = dictMapMap.get(dict.dictType());
			//读取实体中带有注解的字段原有的值
			String dictCode = String.valueOf(FieldUtils.readField(target, field.getName(), true));
			//类型转换,我在库中用的存储的字段是String,所以需要进行类型的转换,如果你的库设置的不是,或者其他的类型,需要注意到这个地方
			Long longDictCode=Long.valueOf(dictCode);
			//拿到字典注解的label
			String dictField = StringUtils.isEmpty(dict.dictField()) ? field.getName() + DICT_FIELD_SUFFIX : dict.dictField();
			//设置字段的字典文本值
			FieldUtils.writeField(target, dictField, dictMap.get(longDictCode), true);
		}
	}

	@Data
	public static class DictDefinition {

		private Dict dict;

		private Field field;

	}

}

系统表的查询接口:

//这个我是在SysDictController下添加的接口,目的是实现查找多个类型的内容,同时还需要写个fegin接口以供别的模块使用
/**
	 * 通过字典类型查找字典
	 * @param types 类型
	 * @return 同类型字典
	 */
	@PostMapping("/types")
	public List<SysDictItem> getDictByTypes(@RequestBody List<String> types) {
		return sysDictItemService.list(Wrappers.<SysDictItem>query().lambda().in(SysDictItem::getDictType, types));
	}

使用方法

直接在实体字段上方添加注解

1.dictType:数据库中的字典的类型

2.dictField:实体中赋值的字段

如上方使用方法所示,如果你想将字典中的label值给实体中的 catalogStructure,则标注

如果前端需要,一个原来的ID,一个label,那就需要创建一个DTO,如图所示,将dictField 后边写上你想要的字段名称就行了

四,备注以及扩展

这个是公司用的pigx框架,没用到的同学,可以跳过了,仅供参考,依据具体项目实际来定

参数的入值:

@AfterReturning(pointcut = "doPointcut()", returning = "result")
	public void doAfterReturning(JoinPoint pjp, Object result) {
		try {
			DictUtils.convertDict(result);
		} catch (Exception e) {
			log.error(String.valueOf(e));
		}
	}

这个代码中的result需要着重注意,他可能会存在判断的问题

举例:这个是正常的,只拿到这个返回的实体就行,在我们实现三中的方法convertDict所有if()的目的都是为了拿到返回值中的实体

比如底下这个图,分页返回的就需要做一个判断(实现三我已经加上了,这个做一个参考):

判断:

五:具体实现 优化1,多个字段的匹配,用“,”隔开代码优化


@Component
public class DictUtils {

	private static final String DICT_FIELD_SUFFIX = "Text";


	/**
	 * 执行字典转换的公共方法
	 * @param target 待转换的对象
	 */
	public static void convertDict(Object target) {
		//判断是否是分页
		if (target instanceof Page) {
			for (Object object : ((Page<?>) target).getRecords()) {
				convertDictInternal(object);
			}
			//是否是list
		} else if (target instanceof List) {
			if (CollectionUtils.isNotEmpty((List<?>) target)) {
				for (Object object : (List<?>) target) {
					convertDictInternal(object);
				}
			}
		} else {
			convertDictInternal(target);
		}
	}

	/**
	 * 内部的字典转换实现方法
	 * @param target 待转换的对象
	 */
	@SneakyThrows
	private static void convertDictInternal(Object target) {
		// 获取目标对象的字典定义
		List<DictDefinition> dictDefinitions = getMetadata(target);
		// 如果没有字典定义则直接返回
		if (CollectionUtils.isEmpty(dictDefinitions)) {
			return;
		}
		// 提取字典类型并获取字典映射
		String commaSeparatedString = dictDefinitions.stream()
				.map(d -> d.getDict().dictType())
				.collect(Collectors.joining(","));
		List<String> dictNames = Arrays.asList(commaSeparatedString.split(","));
		Map<String, Map<Long, String>> dictMapMap = getDictMap(dictNames);
		// 执行具体的字典转换操作
		doConvertDict(target, dictDefinitions, dictMapMap);
	}

	/**
	 * 获取目标对象的字典定义
	 * @param target 目标对象
	 * @return 字典定义列表
	 */
	private  static List<DictDefinition> getMetadata(Object target) {
		List<DictDefinition> dictDefinitions = new ArrayList<>();
		// 排除基本类型、包装类型、Map 和 String 类型
		if (ClassUtils.isPrimitiveOrWrapper(target.getClass())
				|| target instanceof Map || target instanceof String) {
			return dictDefinitions;
		}
         // 获取目标对象的所有字段
		List<Field> fields = FieldUtils.getAllFieldsList(target.getClass());
		for (Field field : fields) {
			// 获取字段上的 Dict 注解
			Dict dict = AnnotationUtils.getAnnotation(field, Dict.class);
			if (dict!= null) {
				// 创建并添加字典定义
				DictDefinition dictDefinition = new DictDefinition();
				dictDefinition.setDict(dict);
				dictDefinition.setField(field);
				dictDefinitions.add(dictDefinition);
			}
		}
		return dictDefinitions;
	}


	/**
	 * 根据字典名称获取字典映射
	 * @param dictNames 字典名称列表
	 * @return 字典映射
	 */
	@SneakyThrows
	private static  Map<String, Map<Long, String>> getDictMap(List<String> dictNames) {
		final RemoteDictService remoteDictService = SpringContextHolder.getBean(RemoteDictService.class);
		List<SysDictItem> dictList = remoteDictService.getDictItemByType(dictNames);
		return new HashMap<>(dictList.stream().collect(Collectors.groupingBy(
				SysDictItem::getDictType, Collectors.toMap(SysDictItem::getId, SysDictItem::getLabel, (v1, v2) -> v2))));
	}


	/**
	 * 执行具体的字典字段转换
	 * @param target 目标对象
	 * @param dictDefinitions 字典定义列表
	 * @param dictMapMap 字典映射
	 */
	@SneakyThrows
	private static  void doConvertDict(Object target, List<DictDefinition> dictDefinitions,
							   Map<String, Map<Long, String>> dictMapMap) {
		for (DictDefinition dictDefinition : dictDefinitions) {
			Dict dict = dictDefinition.getDict();
			Field field = dictDefinition.getField();

			String[] dictTypes = dict.dictType().split(",");
			for (String dictType : dictTypes) {
				Map<Long, String> dictMap = dictMapMap.get(dictType.trim());
				// 读取字段的字典编码
				String dictCode = String.valueOf(FieldUtils.readField(target, field.getName(), true));
				// 类型转换
				Long longDictCode = Long.valueOf(dictCode);
				// 确定字典字段名
				String dictField = StringUtils.isEmpty(dict.dictField())? field.getName() + DICT_FIELD_SUFFIX : dict.dictField();
				// 设置字段的字典文本值
				if (dictMap!= null && dictMap.containsKey(longDictCode)) {
					FieldUtils.writeField(target, dictField, dictMap.get(longDictCode), true);
				}
			}
		}
	}

	@Data
	public static class DictDefinition {

		private Dict dict;

		private Field field;

	}
}

使用方式:

参考:

主要内容参考:

java自定义注解实现数据字典映射_字典映射 java-CSDN博客

分页扩展参考:

字典翻译@Dict - 莫大人 - 博客园

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/912201.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【C#】选课程序增加、删除统计学时

文章目录 【例6-2】编写选课程序。利用利用列表框和组合框增加和删除相关课程&#xff0c;并统计学时数1. 表6-2 属性设置2. 设计窗体及页面3. 代码实现4. 运行效果 【例6-2】编写选课程序。利用利用列表框和组合框增加和删除相关课程&#xff0c;并统计学时数 分析&#xff1…

星期-时间范围选择器 滑动选择时间 最小粒度 vue3

星期-时间范围选择器 功能介绍属性说明事件说明实现代码使用范例根据业务需要,实现了一个可选择时间范围的周视图。用户可以通过鼠标拖动来选择时间段,并且可以通过快速选择组件来快速选择特定的时间范围。 功能介绍 时间范围选择:用户可以通过鼠标拖动来选择时间段。快速选…

Swift从0开始学习 简单值 day1

1.0简单值的初始化: Swift声明值可以使用let 来声明常量&#xff0c;使用 var 来声明变量。let声明是不可修改的且只能赋值一次&#xff0c;var声明是可修改的。两者都不需要显式的指定类型&#xff0c;当你通过一个值来声明变量和常量时&#xff0c;编译器会自动推断其类型。 …

技术领导者的道与术:从领导者到领导力

目录标题 领导者现实看起来是这样技术领导者不应该和个人坐在一起技术领导力仍然是必须的从技术领导到技术领导力小结领导者 你可能想成为或者已经是一位技术领导者,估计你现在心里想成为超级英雄的想法正在爆棚。 你是Java、JavaScript、Angular等技术的专家,公司的项目代…

FPGA视频GTH 8b/10b编解码转PCIE3.0传输,基于XDMA中断架构,提供工程源码和技术支持

目录 1、前言工程概述免责声明 2、相关方案推荐我已有的PCIE方案我已有的 GT 高速接口解决方案 3、PCIE基础知识扫描4、工程详细设计方案工程设计原理框图输入Sensor之-->芯片解码的HDMI视频数据组包基于GTH高速接口的视频传输架构GTH IP 简介GTH 基本结构GTH 发送和接收处理…

【实战篇P2-5】手把手实现STM32+ESP8266+原子云服务器+手机APP应用——第五节-编写Android手机APP程序实现接入原子云服务器

使用的开发软件是Android studio Android SDK(运行环境 :最低版本 21(Android 5.0) 最高版本 29 (Android 9.0) Gradle 版本 :4.6 根据源码,可自定义修改界面,修改名称,根据需求自定义数据展示界面等,修改app图标及名称等。 目录 Android程序设计 Android…

华为私有接口类型hybrid

华为私有接口类型hybrid Tip&#xff1a;hybrid类型&#xff0c;简称混合型接口。 本次实验模拟2层网络下 vlan10 vlan20 不能互访&#xff0c;vlan10 vlan20 同时可以访问vlan100 sw1配置如下&#xff1a; <Huawei>sy [Huawei]sys sw1 [sw1]vl ba 10 20 100 [sw1]int…

在 .NET 6.0 中创建用于 CRUD 操作的 Web API

快速概述&#xff1a; 在动态的技术世界中&#xff0c;创建强大的 Web API 已成为开发人员不可或缺的关键技能。这些 API 是促进不同应用程序之间顺畅通信的重要链接&#xff0c;可实现无缝数据检索和操作。本文的重点是在 .NET 6 中为 CRUD 操作创建 Web API。 为了实现这一点…

YOLOPv2论文翻译

YOLOPv2: Better, Faster, Stronger for Panoptic Driving Perception 摘要 在过去的十年中&#xff0c;多任务学习方法在解决全景驾驶感知问题方面取得了令人鼓舞的成果&#xff0c;既提供了高精度又具备高效能的性能。在设计用于实时实际自动驾驶系统的网络时&#xff0c;这…

使用GitHub Actions实现CI/CD流程

&#x1f493; 博客主页&#xff1a;瑕疵的CSDN主页 &#x1f4dd; Gitee主页&#xff1a;瑕疵的gitee主页 ⏩ 文章专栏&#xff1a;《热点资讯》 使用GitHub Actions实现CI/CD流程 GitHub Actions 简介 创建仓库 配置工作流 示例工作流文件 触发和运行工作流 部署应用 最佳实…

【C++练习】使用海伦公式计算三角形面积

编写并调试一个计算三角形面积的程序 要求&#xff1a; 使用海伦公式&#xff08;Herons Formula&#xff09;来计算三角形的面积。程序需要从用户那里输入三角形的三边长&#xff08;实数类型&#xff09;。输出计算得到的三角形面积&#xff0c;结果保留默认精度。提示用户…

【服务器】使用命令行文本编辑器(如 vim、nano 或 vi)创建文件并编辑

【服务器】使用命令行文本编辑器&#xff08;如 vim、nano 或 vi&#xff09;创建文件并编辑 准备&#xff1a;连接至服务器&#xff08;如ssh&#xff09;创建 .ncl 文件方法 1: 使用 vim 创建 .ncl 文件方法 2: 使用 nano 创建 .ncl 文件确认文件已创建运行 .ncl 文件 总结参…

负载均衡式在线oj项目开发文档(个人项目)

项目目标 需要使用的技术栈&#xff1a; 这个项目共分成三个模块第一个模块为公共的模块&#xff0c;用于解决字符串处理&#xff0c;文件操作&#xff0c;网络连接等等的问题。 第二个模块是一个编译运行的模块&#xff0c;这个模块的主要功能就是将用户的代码收集上来之后要…

区块链技术在数字版权管理中的应用

&#x1f493; 博客主页&#xff1a;瑕疵的CSDN主页 &#x1f4dd; Gitee主页&#xff1a;瑕疵的gitee主页 ⏩ 文章专栏&#xff1a;《热点资讯》 区块链技术在数字版权管理中的应用 区块链技术在数字版权管理中的应用 区块链技术在数字版权管理中的应用 引言 区块链技术概述 …

数据编排与ETL有什么关系?

数据编排作为近期比较有热度的一个话题&#xff0c;讨论度比较高&#xff0c;同时数据编排的出现也暗示着数字化进程的自动化发展。在谈及数据编排时&#xff0c;通常也会谈到ETL&#xff0c;这两个东西有相似点也有不同点。 数据编排和ETL&#xff08;提取、转换、加载&#x…

test 是 JavaScript 中正则表达式对象 (RegExp) 的一种方法,用于测试一个字符串是否匹配某个正则表达式

在你的代码中&#xff0c;test 方法用于验证扫描结果是否符合特定的格式要求。具体来说&#xff0c;/^[A-Za-z\d]{16}$/.test(res.result) 这一行代码用于检查扫描结果 res.result 是否是一个由16个字母或数字组成的字符串。 test 方法的作用 正则表达式匹配&#xff1a; ^ 表…

鸿蒙开发:ArkTS如何读取图片资源

ArkTS在TS的基础上主要扩展了声明式UI能力&#xff0c;简化了构建和更新UI的过程。开发者可以以声明式的方式来描述UI的结构&#xff0c;如使用build方法中的代码块。同时&#xff0c;ArkTS提供了自定义组件、系统组件、属性方法、事件方法等&#xff0c;以构建应用UI界面。今天…

外贸管理利器7选,助力高效办公

推荐7款外贸管理软件&#xff0c;包括ZohoBooks、ZohoCRM、富通天下等&#xff0c;各具特色&#xff0c;满足外贸企业不同需求&#xff0c;提高管理效率&#xff0c;助力企业全球化竞争。、 一、Zoho Books Zoho Books是一款外贸财务管理软件&#xff0c;不仅为用户提供了一个…

Powerfx日期的設定

踏入2024年年尾 &#xff0c;即將開始2025年 &#xff0c; 很多事又到了一個新的開始&#xff0c;我們的Microsoft windows 也一樣&#xff0c;就是要對Windows 10說再見&#xff0c;踏入Windows 11&#xff0c;對大多數用戶來說Windows 11既很接近又很遙遠&#xff1b;這是因為…

单应用系统规划Node(节点)、Menu(菜单) 和 User(用户)之间关系

标题中的内容涉及到了系统设计中的权限管理和功能模块化。 通过设计一个 Node 系统 来实现更灵活、更安全的权限控制。Node 更像是一个概念&#xff0c;但在实际应用中&#xff0c;它可以具象化为数据库中的表结构&#xff0c;进而与 Menu 和 User 权限系统关联起来。 Node 系统…