Skip to content

开发背景

实习负责 ERP 系统销售模块,有生产成本等价格字段的权限控制需求,正好芋道不支持字段级权限控制,遂扩展支持了这部分权限系统。

实现方法

由于是 B 端传统软件,不存在明显的性能瓶颈,同时也为了保持业务代码的低侵入性,选择在 Spring MVC 响应处理链上注册一个 ControllerAdvice ,拦截带有
@FieldPermissionController 响应体传递,对返回的 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 响应流之前,对响应数据进行统一处理和修改。

java
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 的方法带上注解元信息:

java
@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 中传入:

java
try {  
    this.returnValueHandlers.handleReturnValue(  
          returnValue, getReturnValueType(returnValue), mavContainer, webRequest);  
}

这个是 Spring 最常用的包装工具类,其中存储了:

java
private final Executable executable;

@Nullable  
public Method getMethod() {  
    return (this.executable instanceof Method ? (Method) this.executable : null);  
}

通过 getMethod 拿到 Controller 的方法反射信息,这里使用的是 Spring 反射而非 Java 原生反射,以支持父类注解继承。无论是 Java 原生反射还是 Spring 反射,都不支持类注解传播到方法上,虽然很多注解有这样的行为,但是这是通过既查找方法注解又查找类注解的兜底机制实现的:

java
@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

java
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 结构下该用户角色对某个字段是否具有访问权限。

参数配置:

java
// 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 键:

java
@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][刷新缓存成功]");  
}