开发背景
实习负责 ERP 系统销售模块,有生产成本等价格字段的权限控制需求,正好芋道不支持字段级权限控制,遂扩展支持了这部分权限系统。
实现方法
由于是 B 端传统软件,不存在明显的性能瓶颈,同时也为了保持业务代码的低侵入性,选择在 Spring MVC 响应处理链上注册一个 ControllerAdvice ,拦截带有@FieldPermission 的 Controller 响应体传递,对返回的 VO 类进行反射操作,将权限控制的字段写 null,随后配合 @JsonInclude(JsonInclude.Include.NON_NULL) 注解,在序列化 Json 返回前端的时候过滤掉无权限字段,实现权限控制。
通过 Spring MVC Controller 链路实现权限注解处理器
Controller 的处理由 DispatchServlet 完成,其中会在调用 Controller 方法前调用一系列前置拦截器。
通过 ControllerAdvice, @Order(Ordered.HIGHEST_PRECEDENCE + 1) 注册一个最高优先级的 Controller 增强器(Advice,AOP 下通常表示在某个执行点插入的代码,对原有逻辑进行解耦补充),实现 ResponseBodyAdvice 接口,用于在 Spring MVC 控制器方法返回响应数据后,但在响应被写入 HTTP 响应流之前,对响应数据进行统一处理和修改。
public interface ResponseBodyAdvice<T> {
/**
* 判断是否支持当前请求的拦截处理
*
* <p>该方法在响应被写入之前调用,用于决定是否需要对当前控制器的返回值进行
* 后续的{@link #beforeBodyWrite}处理。
*
* @param returnType 控制器方法的返回类型,可用于判断返回类型、注解等元数据
* @param converterType 即将用于写入响应的消息转换器类型
* @return {@code true} 表示需要调用{@link #beforeBodyWrite}方法进行处理;
* {@code false} 表示跳过处理
*/
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
/**
* 在消息转换器写入响应体之前对数据进行处理
*
* <p>当{@link #supports}方法返回{@code true}时,该方法会被调用。
* 可以在该方法中对原始响应体进行修改、包装或替换。
*
* @param body 控制器方法返回的原始数据对象,可能为{@code null}
* @param returnType 控制器方法的返回类型
* @param selectedContentType 通过内容协商选定的MediaType
* @param selectedConverterType 选定的消息转换器类型
* @param request 当前的服务器HTTP请求
* @param response 当前的服务器HTTP响应
* @return 返回将要写入响应的数据对象,可以是原始对象,也可以是修改后的新对象
*/
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}首先我们定义 @FieldPermission 注解,让 Controller 的方法带上注解元信息:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldPermission {
}
@GetMapping("/get")
@Operation(summary = "获得订单子项信息")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('sales:order-item:query')")
@FieldPermission
public CommonResult<SalesOrderItemRespVO> getOrderItem(@RequestParam("id") Long id) {
SalesOrderItemDO orderItem = orderItemService.getOrderItem(id);
return success(BeanUtils.toBean(orderItem, SalesOrderItemRespVO.class));
}然后在 Advice 中实现接口 support 方法,值得一提的是,support 会传入一个 MethodParameter 类型的包装对象,在 ServletInvocableHandlerMethod 中传入:
try {
this.returnValueHandlers.handleReturnValue(
returnValue, getReturnValueType(returnValue), mavContainer, webRequest);
}这个是 Spring 最常用的包装工具类,其中存储了:
private final Executable executable;
@Nullable
public Method getMethod() {
return (this.executable instanceof Method ? (Method) this.executable : null);
}通过 getMethod 拿到 Controller 的方法反射信息,这里使用的是 Spring 反射而非 Java 原生反射,以支持父类注解继承。无论是 Java 原生反射还是 Spring 反射,都不支持类注解传播到方法上,虽然很多注解有这样的行为,但是这是通过既查找方法注解又查找类注解的兜底机制实现的:
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
Method method = returnType.getMethod();
if (method == null) {
return false;
}
// 方法级注解
if (AnnotationUtils.findAnnotation(method, FieldPermission.class) != null) {
log.debug("supports: 命中方法注解 FieldPermission, method={}", method.getName());
return true;
}
// 类级注解
Class<?> targetClass = returnType.getContainingClass();
if (AnnotationUtils.findAnnotation(targetClass, FieldPermission.class) != null) {
log.debug("supports: 命中类注解 FieldPermission, class={}", targetClass.getSimpleName());
return true;
}
return false;
}缓存机制
通过 Caffeine + Redis + Mysql 三级数据访问,保证鉴权性能。
缓存接口 FieldPermissionChecker
package cn.iocoder.yudao.module.system.fieldPermission;
import lombok.Getter;
import java.util.Set;
/**
* 字段权限检查器接口
* 用于判断某个角色是否有权访问某个 VO 的某个字段
*/
public interface FieldPermissionChecker {
/**
* 判断指定角色是否允许查看某 VO 的某字段
*
* @param roleCode 角色编码(如 2)
* @param voClassName VO 类名(如 "cn.iocoder.yudao.module.sales.vo.CustomerVO")
* @param fieldName 字段名(如 "phone")
* @return 是否允许访问
*/
boolean isFieldAllowed(Integer roleCode, String voClassName, String fieldName);
/**
* 批量判断多个字段是否允许访问(优化性能,减少多次 lookup)
*
* @param roleCode 角色编码
* @param voClassName VO 类名
* @param fieldNames 字段名集合
* @return 不允许访问的字段集合
*/
Set<String> checkNotAllowedFields(Integer roleCode, String voClassName, Set<String> fieldNames);
/**
* 获取该角色对该 VO 所有允许访问的字段(可用于预加载)
*
* @param roleCode 角色编码
* @param voClassName VO 类名
* @return 不允许的字段集合
*/
Set<String> getAllNotAllowedFields(Long roleCode, String voClassName);
/**
* 刷新缓存(当权限配置变更时调用)
* 实现类可根据需要清空本地缓存、删除 Redis 缓存等
*/
void refreshCache();
/**
* 获取缓存统计信息(可选,用于监控)
*
* @return 统计信息(如命中率、大小等)
*/
CacheStats getCacheStats();
/**
* 缓存统计内部类
*/
@Getter
class CacheStats {
// getter
private final long hitCount;
private final long missCount;
private final long totalSize;
public CacheStats(long hitCount, long missCount, long totalSize) {
this.hitCount = hitCount;
this.missCount = missCount;
this.totalSize = totalSize;
}
public double getHitRate() {
long total = hitCount + missCount;
return total == 0 ? 0.0 : (double) hitCount / total;
}
}
}Caffeine
使用 Caffeine 作为本地内存缓存,减少 Redis 和数据库回源流量,维护 Cache<FieldPermissionKey, Boolean>: (roleCode,voClassName, fieldName) 三元组结构和 VO 对应的所有禁止字段两种缓存结构,即对应 VO 结构下该用户角色对某个字段是否具有访问权限。
参数配置:
// key: (role, vo, field) -> Boolean
// 单字段是否可见
private final Cache<FieldPermissionKey, Boolean> localCache;
// key: (role, vo) -> Set<String>
// VO 表对应所有的禁止字段缓存
private final Cache<FieldPermissionKeyAll, Set<String>> notAllowedFieldsCache;
public FieldPermissionCacheService() {
this.localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterAccess(10, TimeUnit.MINUTES)
.recordStats()
.build();
this.notAllowedFieldsCache = Caffeine.newBuilder()
.maximumSize(2_000)
.expireAfterAccess(10, TimeUnit.MINUTES)
.recordStats()
.build();
}- maximumSize:缓存中允许的最大条目数,上限溢出后会移除最近最少使用的数据。
- expireAfterAccess:某条数据如果在设定时间内没有被访问,就会自动过期并被移除。
- expireAfterWrite:某条数据自写入(加入缓存)后,达到指定时间即失效(不管是否被访问过)。
- recordStats:开启缓存的使用统计信息(如命中、未命中、移除次数等),方便性能监控。
- initialCapacity:缓存初始化时分配的槽位大小,提升高并发场景下的初始性能。
由于单权限形式(roleId:ClassName:fieldName)结果会明显多于对 Set 集合的缓存,所以分配空间更大。
Redis
这里使用 stringRedisTemplate 操作 Redis,避免序列化、反序列化的麻烦。采用 private final String REDIS_KEY_PREFIX = "field_permission:" 作为 Redis 命名空间。 单权限缓存使用 String 类型(TTL = 24H),VO 表组字段(TTL = 2H)缓存采用 Set 类型。
刷新缓存时,通过命名空间前缀模糊匹配批量删除 Redis 键:
@Override
public void refreshCache() {
localCache.invalidateAll();
notAllowedFieldsCache.invalidateAll();
Set<String> keys1 = stringRedisTemplate.keys(REDIS_KEY_PREFIX + "*");
if (!keys1.isEmpty()) {
stringRedisTemplate.delete(keys1);
}
Set<String> keys2 = stringRedisTemplate.keys(REDIS_NOT_ALLOWED_SET_PREFIX + "*");
if (!keys2.isEmpty()) {
stringRedisTemplate.delete(keys2);
}
log.info("[refreshCache][刷新缓存成功]");
}