芋道字段级权限扩展

开发背景

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

实现方法

由于是 B 端传统软件,不存在明显的性能瓶颈,同时也为了保持业务代码的低侵入性,选择在 Spring MVC 响应处理链上注册一个 ControllerAdvice ,拦截带有
@FieldPermissionController 响应体传递,对返回的 VO 类进行反射操作,将权限控制的字段写 null,随后配合 @JsonInclude(JsonInclude.Include.NON_NULL) 注解,在序列化 Json 返回前端的时候过滤掉无权限字段,实现权限控制。

Controller 方法接收请求
方法或类
是否标注@FieldPermission?
直接返回正常响应
业务代码返回 VO/List/PageResult
进入 FieldPermissionAdvice
(ResponseBodyAdvice 拦截)
获取当前登录用户角色
查缓存获该角色下无权限字段集合
通过反射将无权限字段在 VO 上 set null
回传被清洗后的 VO/List/PageResult
Spring MVC 提交给 Jackson 序列化处理
VO 类上
有@JsonInclude.NON_NULL?
所有字段都输出(含 null)
为 null 字段被自动遗漏
(无权限字段在 JSON 中自动消失)
最终生成 JSON 响应,返回前端

缓存机制

通过 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][刷新缓存成功]");  
}
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yyt363045841

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值