芋道字段级权限扩展
开发背景
实习负责ERP系统销售模块,有生产成本等价格字段的权限控制需求,正好芋道不支持字段级权限控制,遂扩展支持了这部分权限系统。
实现方法
由于是 B 端传统软件,不存在明显的性能瓶颈,同时也为了保持业务代码的低侵入性,选择在 Spring MVC 响应处理链上注册一个 ControllerAdvice
,拦截带有
@FieldPermission
的 Controller
响应体传递,对返回的 VO 类进行反射操作,将权限控制的字段写 null,随后配合 @JsonInclude(JsonInclude.Include.NON_NULL)
注解,在序列化 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][刷新缓存成功]");
}