Java

发布于 更新于

AI总结: 本文介绍了RBAC(基于角色的访问控制)权限模型的基本概念,包括用户、角色和权限之间的关系,以及如何通过角色将用户与权限相结合。权限分为功能权限、按钮权限、数据权限和租户权限,并介绍了权限校验流程、双Token机制、Mock Token的开发测试、框架组件的操作日志和访问日志处理等。还提到了多租户DB实现的三种方案及其维护成本、隔离性和性能的比较,以及缓存扩展和分布式锁/限流的实现方式。最后,概述了幂等控制和流控的相关配置。 改进建议:可以进一步简化和组织内容,使其更易于理解,尤其是对于权限校验流程和多租户实现部分,可以提供更多的实例和应用场景以增强实用性。

框架

RBAC权限模型

RBAC 基于角色的访问控制

简而言之,共有三个类型(用户、角色、权限),用户和权限通过角色进行交集绑定,也就是某一个用户有哪一些角色,这些角色有哪一些权限。只要用户和角色、角色和权限之间存在交集,就是该用户具有某一个权限。

  • 用户(User) —— 可以拥有多个 角色(Role)
  • 角色(Role) —— 可以拥有多个 菜单/权限(Menu/Permission)
  • 菜单(Menu) —— 包含目录、菜单、按钮(操作权限),支持 权限标识(permission)system:user:query

关系总结
- 用户 ↔ 角色(多对多)
- 角色 ↔ 菜单(多对多)
- 菜单中定义 按钮/接口权限(如 user:adduser:edit 等)

权限类型

权限类型 说明 实现方式
功能权限 控制用户能看到哪些菜单、按钮,能调用哪些接口 动态路由 + @PreAuthorize 或自定义注解
按钮权限 细粒度到页面按钮(如新增、删除、导出按钮) 权限标识(permission) + 前端 v-hasPermi 指令
数据权限 控制用户能看到哪些数据(本部门、本人、自定义部门等) @DataScope 注解 + MyBatis 拦截器重写 SQL
租户权限 SaaS 多租户隔离,每个租户可独立配置菜单、角色、数据权限 Tenant 过滤器 + TenantRedisCacheManager

权限校验流程

后端
- 使用 Spring Security + JWT/Token
- 登录后加载用户所有角色和权限,存入 Redis(使用 TimeoutRedisCacheManager 缓存权限信息)
- 接口权限校验:@PreAuthorize("hasPermission('system:user:query')") 或自定义 Security 表达式
- 数据权限:MyBatis 拦截器(DataScopeAspect)在 SQL 执行前动态追加 WHERE 条件

前端
- 登录后获取用户菜单树(动态路由)
- 使用 v-hasPermi="'system:user:add'"v-hasRole="'admin'" 控制按钮显示
- 路由守卫中进行权限校验,防止未授权页面访问

双Token系统

双Token机制出现的需求是: 需要既能高频使用来保证接口安全(短效Access Token),又能长期保持登录状态从而不打扰用户(长效Refresh Token)

设置访问令牌,先将访问令牌设置到数据库中,之后设置到Redis中。
刷新令牌可以不需要设置到Redis中,原因是这个刷新令牌的过期时间很长30天。

如果用户注销登录,删除访问令牌(删除数据库中的和Redis中的),之后删除刷新令牌。

开发测试Mock Token

virgo:  
  security:  
    mock-enable: true  # 是否开启Mock功能  
    mock-secret: test  # 设置Mock密钥 不设置默认为test  
# 请求头示例  
# Token必须以{{mock-secret}}开头, 后面跟用户编号, 如test1  
Authorization: Bearer test1  

相关类:
- com.virgo.framework.security.config.SecurityProperties 配置文件
- com.virgo.framework.security.core.filter.TokenAuthenticationFilter#mockLoginUser() Mock实现逻辑

实现URL是否需要登录

com.virgo.framework.security.config.VirgoWebSecurityConfigurerAdapter

filterChain(HttpSecurity httpSecurity) URL 安全配置
1. getPermitAllUrlsFromAnnotations() 获取所有@PermitAll注解的接口
2. com.virgo.module.system.framework.security.config.SecurityConfiguration 业务模块自定义安全配置
3. 配置文件配置免登录URL

virgo:  
  security:  
    permit-all-urls:  
      - /admin-api/system/auth/login  
      - /admin-api/system/auth/logout  
      - /admin-api/system/auth/refresh-token  

框架组件

操作日志、访问日志、错误日志处理

访问日志

com.virgo.framework.apilog.core.annotation.ApiAccessLog 注解定义
com.virgo.framework.apilog.core.filter.ApiAccessLogFilter 拦截器处理

@ApiAccessLog
enable: 是否记录访问日志
requestEnable: 是否记录请求参数
responseEnable: 是否记录响应结果
sanitizeKeys: 敏感参数数组, 配置后不会输出到日志

本地开发调试

spring-boot-starter-env组件,通过tag给服务打标签,实现在同一个注册中心的情况下,本地只需要正常启动服务,保证自己的请求只会打到自己的服务。

实现方法

com.virgo.framework.env.config.EnvEnvironmentPostProcessor
将 virgo.env.tag 设置到 nacos 等组件对应的 tag 配置项,当且仅当它们不存在时

virgo:  
  env: # 多环境的配置项  
    tag: ${HOSTNAME}  

EnvironmentPostProcessor是一个用于在Spring环境准备完成之后、应用上下文创建之前,对配置环境进行自定义处理。

启动流程:

  1. SpringApplication.run();
  2. 环境准备时期:创建ConfigurableEnvironment对象。
  3. 属性加载:加载application.yaml文件配置。
  4. EnvironmentPostProcessor执行-自定义环境处理。
  5. 应用上下文创建-创建ApplicationContext。
  6. Bean加载和初始化-完成应用的启动。

由于这个EnvironmentPostProcessor的执行时机是在nacos执行之前,所以在nacos加载的时候可以做到侵入式的添加metadata = {tag}。

需要内网穿透访问本机, 否则请求会报错, 如:

io.netty.channel.ConnectTimeoutException: connection timed out after 30000 ms: /198.18.0.1:48087  

负载均衡

com.virgo.gateway.filter.grey.GrayLoadBalancer 灰度负载均衡, 支持按version, tag调用请求
1. 服务发布时设置version, 自动设置到Nacos的元数据metadata中, 如version:1.0.0, 需在本地application.yaml中配置
2. 可以同时发布同一服务不同版本
3. 请求时未带version, 所有实例中选择
4. 请求时带了version, 根据version匹配实例进行选择
5. version未匹配到实例, 兜底在所有实例中选择
6. tag一般用于本地开发调试, 所以请求未带tag时,过滤所有tag实例, 以免打到本地环境.

SaaS多租户

技术组件

多租户DB实现

实现方案:

方案一: 分库 方案二: 同库分表 方案三: 同库同表字段区分
维护成本
隔离性
性能
硬件成本
备份/还原 简单 困难

方案一二: 采用Mycat, Sharding-Sphere分库分表
方案三: 基于MyBatis-Plus的tenant_id字段,自动进行过滤 WHERE tenant_id = ?Mybatis-plus插件

多租户实现

virgo-spring-boot-starter-biz-tenant

MBP实例

com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler
1. getTenantId(获取租户ID值的表达式)
2. getTenantIdColumn(获取租户的字段名,默认是:tenant_id)
3. ignoreTable(根据表名判断是否需要忽略拼接多租户条件)
4. ignoreInsert(忽略插入租户字段逻辑)

com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor

大概就是两种,一种是关于租户ID值的表达式,另一种是是否忽略掉一些表结构。

com.virgo.framework.tenant.config.TenantProperties
com.virgo.framework.tenant.config.VirgoTenantAutoConfiguration
com.virgo.framework.tenant.core.security.TenantSecurityWebFilter
com.virgo.framework.tenant.core.db.TenantDatabaseInterceptor
com.virgo.framework.tenant.core.web.TenantVisitContextInterceptor 跨租户访问
com.virgo.framework.tenant.core.redis.TenantRedisCacheManager 多租户缓存实现

virgo:  
  tenant: # 多租户相关配置项  
    enable: true # 租户是否开启  
    ignore-urls: # 忽略多租户的请求  
      - /jmreport/* # 积木报表,无法携带租户编号  
    ignore-visit-urls: # 忽略跨租户的请求  
      - /admin-api/system/user/profile/**  
      - /admin-api/system/auth/**  
    ignore-tables: # 忽略多租户的表  
    ignore-caches: # 忽略多租户的Spring Cache缓存  
      - user_role_ids  
      - permission_menu_ids  
      - oauth_client  

缓存扩展

使Spring Cache支持失效时间
com.virgo.framework.redis.core.TimeoutRedisCacheManager

@Cacheable(cacheNames = "name 0X1 苹果CMS接口说明以及采集源搜索 d", key = "key")。

cacheNames格式为 "key#ttl" 时,# 后面的 ttl 为过期时间。
单位为最后一个字母(支持的单位有:d 天,h 小时,m 分钟,s 秒),默认单位为 s 秒

Lock4j 分布式锁/限流

Lock4j使用帮助

默认配置项

lock4j:  
  acquire-timeout: 3000 #默认值3s,可不设置  
  expire: 30000 #默认值30s,可不设置  
  primary-executor: com.baomidou.lock.executor.RedisTemplateLockExecutor #默认redisson>redisTemplate>zookeeper,可不设置  
  lock-key-prefix: lock4j #锁key前缀, 默认值lock4j,可不设置  
 // 默认获取锁超时3秒,30秒锁过期  
 // redisKey: "redisson_unlock_latch:{lock4j:com.xxx.Lock4jControllertest#}:a2a4ace0773a9209027618d3acfbe502"  
 @Lock4j  

 // 指定获取锁超时1秒, 60秒锁过期  
 // "redisson_unlock_latch:{lock4j:com.xxx.Lock4jControllertest1 [0X1 苹果CMS接口说明以及采集源搜索](https://anaer.github.io/blog/post/1.html) .2}:7c9e1923f21038141ec9fc93172f80e5"  
 @Lock4j(keys = {"#user.id", "#user.name"}, expire = 60000, acquireTimeout = 1000)  

 // 用户在5秒内只能访问1次  
 // "lock4j:com.xxx.Lock4jControllertest2 [0X1 苹果CMS接口说明以及采集源搜索](https://anaer.github.io/blog/post/1.html) "  
 @Lock4j(keys = {"#user.id"}, acquireTimeout = 0, expire = 5000, autoRelease = false)  

 // 默认获取锁超时3秒,30秒锁过期 指定锁名称  
 // "lock4j:lockName [0X1 苹果CMS接口说明以及采集源搜索](https://anaer.github.io/blog/post/1.html) "  
 @Lock4j(name = "lockName")  

 // 用户在5秒内只能访问1次 指定锁名称  
 // "redisson_unlock_latch:{lock4j:lockName#}:fd80f8c0116e2c3af7b20e6cee85467e"  
 @Lock4j(keys = {"#user.id"}, name = "lockName", acquireTimeout = 0, expire = 5000, autoRelease = false)  
  1. Lock4j未指定name时, 会根据包名+类名+方法名生成name, 如: com.xxx.Lock4jControllertest, 类名与方法名间没有分隔符

  2. keys会以.拼接, 如1.2, name与keys以#分隔

  3. key前缀通过配置lock-key-prefix获取

https://github.com/baomidou/lock4j/blob/381551f5fe07f546addafff25f4e02cebe60bdc5/lock4j-core/src/main/java/com/baomidou/lock/aop/LockInterceptor.java#L74-L77

  1. 自释放key因为基于redisson锁释放通知机制, 会再包一层 redisson_unlock_latch:{lock_name}:{requestId}

https://github.com/redisson/redisson/blob/2edc2bb807afe73f2997777716f6d61274bc7264/redisson/src/main/java/org/redisson/RedissonBaseLock.java#L213

  1. Lock4j注解可以指定keyBuilderStrategy key生成策略和 failStrategy 失败策略

默认Key生成策略
https://github.com/baomidou/lock4j/blob/381551f5fe07f546addafff25f4e02cebe60bdc5/lock4j-core/src/main/java/com/baomidou/lock/DefaultLockKeyBuilder.java

默认失败策略
https://github.com/baomidou/lock4j/blob/381551f5fe07f546addafff25f4e02cebe60bdc5/lock4j-core/src/main/java/com/baomidou/lock/DefaultLockFailureStrategy.java

全局修改默认失败策略

package com.virgo.framework.lock4j.config;  

import com.virgo.framework.lock4j.core.DefaultLockFailureStrategy;  
import com.baomidou.lock.spring.boot.autoconfigure.LockAutoConfiguration;  
import org.springframework.boot.autoconfigure.AutoConfiguration;  
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;  
import org.springframework.context.annotation.Bean;  

@AutoConfiguration(before = LockAutoConfiguration.class)  
@ConditionalOnClass(name = "com.baomidou.lock.annotation.Lock4j")  
public class VirgoLock4jConfiguration {  

    @Bean  
    public DefaultLockFailureStrategy lockFailureStrategy() {  
        return new DefaultLockFailureStrategy();  
    }  

}  

Idempotent 幂等控制

保证一次请求只生效一次(业务幂等)

// "idempotent:f89875e7eb300d1fc65ea9d6e439153b"  
@Idempotent  

@Idempotent(timeout = 10, keyResolver = UserIdempotentKeyResolver.class)  

DefaultIdempotentKeyResolver 默认全局Key规则, 根据MD5(方法名+参数)生成Key
UserIdempotentKeyResolver 用户基本Key规则, 根据MD5(方法名+参数+userId+userType)生成Key
ExpressionIdempotentKeyResolver 自定义表达式, 结合 keyArg 参数使用

key前缀在IdempotentRedisDAO定义

RateLimiter 流控

// 默认1秒100次  
// 1) "{rate_limiter:a06d9953d9dd6ef9179aa0febb238975}:value" String 剩余量  
// 2) "{rate_limiter:a06d9953d9dd6ef9179aa0febb238975}:permits" Zset 已分配量  
// 3) "rate_limiter:a06d9953d9dd6ef9179aa0febb238975" Hash 流控信息  
@RateLimiter  

// 10分钟3次  
@RateLimiter(time = 10, timeUnit = TimeUnit.MINUTES, count = 3, keyResolver = DefaultRateLimiterKeyResolver.class)  

DefaultRateLimiterKeyResolver 全局级别 根据MD5(方法名+参数)生成Key
UserRateLimiterKeyResolver 用户 ID 级别 根据MD5(方法名+参数+userId+userType)生成Key
ClientIpRateLimiterKeyResolver 用户 IP 级别 根据MD5(方法名+参数+clientIp)生成Key
ServerNodeRateLimiterKeyResolver 服务器 Node 级别 根据MD5(方法名+参数+host+pid)生成Key
ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算

版本更新

v2026.01 -> v2026.03