先说个场景:同一个订单列表接口,总部运营登录后能看到全部订单、客户手机号原文和成本价;区域经理只能看到所属区域的订单,手机号中间四位要打码,成本价更不能显示;普通销售员只能看到自己名下订单,列表页上连个导出按钮都没有。
这就是我接手某企业内部管理系统时遇到的需求。客户明确不要“角色一堆、权限糊墙”的粗放模式,要求做到“谁能看、谁能改、能看哪几行、能看哪几列”全部可配置,而且权限调整后要即时生效,不能每次改权限都重启服务。技术栈指定 SpringBoot,权限框架让我们自己选。最后我选了 Shiro,并基于它做了整套细粒度动态权限设计。
这篇内容不聊概念,只讲落地。我会把权限码设计、表结构、动态权限链路、数据级和字段级权限的写法规规矩矩梳理一遍,最后附上生产环境踩过的几个坑和排查过程。项目里用的是 SpringBoot 2.7 + Shiro 1.9,思路在 Spring Boot 3.x 上同样适用,只要注意 jakarta 命名空间迁移就行。
1. 先说结论:为什么在这个项目里我选了 Shiro 而不是 Spring Security
1.1 从需求反推选型:动态细粒度到底需要什么
把需求拆开看,动态细粒度权限系统本质上只需要四样东西:能随时改的权限规则、能即时刷新的运行时权限集合、能判断到按钮甚至数据行的校验机制、以及一套给管理员操作的授权界面。
Spring Security 生态确实更完整,OAuth2、OIDC、方法安全表达式这些都很成熟。但这个项目里没有外部认证服务器接入需求,用户体系就是企业内部账号密码,登录后签发 Session 或 Token,后端接口按权限码校验。这种情况下 Spring Security 的过滤器链反而显得重——配置项多、组件之间的关系复杂,新接手的人光搞懂过滤器顺序就要花不少时间。
Shiro 的优势在于它的认证授权模型足够直观:Subject 代表当前用户,SecurityManager 管全局,Realm 负责从数据源加载认证和授权信息。你要做定制化权限,直接替换 Realm、自定义 PermissionResolver、在过滤器链里插入自己的过滤器,改动面非常小,不会牵连整个安全框架。
1.2 Shiro 与 Spring Security 的取舍,以及不装懂的边界
我不主张无脑吹 Shiro。如果你要做的项目涉及第三方 OAuth2 联合登录、社会化登录、或者要求强规范的方法安全表达式,Spring Security 会更省心。但如果是企业内部后台管理系统,权限模型需要高度定制,尤其是要做数据级权限、字段级权限这种偏业务的东西,Shiro 的轻量反而成了优势。
用一张表总结我当时的核心对比项:
| 维度 | Shiro | Spring Security |
|---|---|---|
| 学习成本 | 认证授权链路短,半天能理清 | 过滤器链、安全上下文、配置体系复杂 |
| 权限模型扩展 | 自定义 Realm / Permission / Filter 即可 | 扩展点分散在不同组件中 |
| 注解支持 | @RequiresPermissions,直白 | @PreAuthorize,配合 SpEL |
| 数据级权限支持 | 需要自己设计,框架不限制 | 需要自己设计,框架不限制 |
| 外部认证生态 | 弱 | 强 |
一句话:权限真正复杂的部分在业务层建模,不在框架本身。Shiro 给了够用的底座,剩下的权限码设计、动态刷新、数据范围控制,都需要你自己写。这篇博客的核心也在这里。
2. 细粒度权限模型:从“能访问”到“能操作到哪一行哪一列”
2.1 超越 RBAC 的权限码设计
传统 RBAC 的权限码通常是system:user:list这种三段式:模块:功能:操作。用户拥有这个权限码,就能调用对应接口。这在菜单级控制时够用,但一旦业务要求“同一个接口,不同角色看到不同的数据范围”,三段式就表达不了了。
我最终的权限码设计是四段式:
资源模块:功能:操作:数据范围数据范围这一段的取值固定几类:all(全部数据)、dept(本部门及以下)、self(仅本人)、custom(指定组织范围)、none(无数据权限,通常是新增、修改这类不查列表的操作)。
举例说明更清楚:
order:list:view:all—— 查看全部订单order:list:view:dept—— 查看本部门订单order:list:view:self—— 查看本人订单order:export:run:none—— 导出订单,无数据范围限制(导出范围仍需要后端二次校验)
字段级权限不在这个编码里表达,而是单独作为“字段资源”挂在资源表上。比如order:field:customer_mobile表示可以看到客户手机号,order:field:cost_price表示可以看到成本价。字段权限和数据范围是两个正交维度,分开设计才能灵活组合。
2.2 数据库表结构:把规则变成数据
既然要做到动态调整权限,规则就必须全部落到数据库里。这是整个系统能“动态”的前提。我最后用了六张核心表:用户表、角色表、用户角色关联表、资源表、角色资源关联表、用户数据范围覆盖表。
-- 用户表,核心字段省略 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, dept_id BIGINT, status TINYINT DEFAULT 1 ); -- 角色表 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY, role_code VARCHAR(64) NOT NULL UNIQUE, role_name VARCHAR(128) NOT NULL, status TINYINT DEFAULT 1, remark VARCHAR(512) ); -- 用户角色关联表 CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, UNIQUE KEY uk_user_role (user_id, role_id) ); -- 资源表:菜单、按钮、接口、字段都放这里 CREATE TABLE sys_res ( id BIGINT PRIMARY KEY, res_code VARCHAR(128) NOT NULL UNIQUE, res_name VARCHAR(128), res_type TINYINT COMMENT '1菜单 2按钮 3接口 4字段', parent_id BIGINT, action_url VARCHAR(256), sort_no INT, status TINYINT DEFAULT 1 ); -- 角色资源关联表:某个角色拥有哪些资源,以及该资源的数据范围 CREATE TABLE sys_role_res ( id BIGINT PRIMARY KEY, role_id BIGINT NOT NULL, res_id BIGINT NOT NULL, data_scope VARCHAR(16) DEFAULT 'none' COMMENT 'all/dept/self/custom/none', custom_dept_ids VARCHAR(1024), UNIQUE KEY uk_role_res (role_id, res_id) );sys_res 表是整个权限模型的根基。菜单、按钮、接口、字段全部统一登记成资源,角色授权时勾选的就是这个权限树。数据范围虽然最常挂在“列表查询”这一类资源上,但表结构上它是 sys_role_res 的一个属性,意味着同一个资源,不同角色可以给不同数据范围,这是细粒度权限的关键点。
举个例子:order:list:view这个资源 ID 是 1001。总部运营角色在 sys_role_res 里配data_scope=all;区域经理角色配data_scope=dept;销售员角色配data_scope=self。授权的时候管理员在界面上勾哪个范围,后端存哪个范围,代码逻辑完全不用改。
2.3 数据范围与字段权限怎么落到资源表里
字段权限我建议单独用 res_type=4 的资源登记。比如订单模块下挂四个字段资源:order:field:order_no、order:field:customer_name、order:field:customer_mobile、order:field:cost_price。角色勾选了order:field:customer_mobile,查询列表时这个字段就显示明文;没勾选就脱敏或置空。
这里有个容易犯的错误:把字段权限挂在菜单资源下面作为子节点,然后通过判断“有没有父菜单权限”来推断字段权限。千万别这么干,因为权限校验的是叶子节点的权限码,父级权限不能自动推导子级权限,否则就会出现“有菜单权限但没有字段权限的角色的字段显示逻辑无法单独控制”的问题。
3. 动态权限的核心链路:一次请求如何命中最新规则
3.1 从登录到鉴权的完整调用链
动态权限系统运行时的请求链路是这样的:
客户端请求进入后,先经过 Shiro 的过滤器链,完成身份认证(判断当前用户是否已登录)。认证通过后,请求落到业务接口之前,会触发权限校验。权限校验时,Shiro 先看当前 Subject 是否拥有这个权限码,而权限码集合是从 Realm 的doGetAuthorizationInfo方法加载的。
关键点在于:Realm 加载权限集合时我不会每次请求都查数据库,而是走两级缓存——本地 Caffeine 缓存 + Redis 共享缓存。权限集合从库里加载后,缓存在内存中。这样权限校验阶段性能损耗极低,绝大多数请求的授权校验在 1 到 2 毫秒内完成。
那“动态”体现在哪?体现在两个可以被运行时刷新点上:URL 级权限规则映射、用户级权限集合缓存。
3.2 URL 级动态授权的实现思路
Shiro 的 FilterChain 定义通常是在启动时固定的:/admin/** = authc, perms["admin:manage"]。如果改数据库里的权限规则,FilterChain 不会自动刷新。我这里的方案是不依赖 perms[] 过滤器,而是自己实现一个动态权限过滤器。
自定义一个DynamicUrlPermsFilter,它内部维护一份 URL 到所需权限码的映射快照,用一个 ConcurrentHashMap 存着。每次请求进入过滤器时,根据请求的 URI 从 map 里查出对应的权限码,再调用 Subject 的checkPermission方法来校验。权限管理员在后台改了权限规则后,会触发一次reload(),重新从数据库加载最新的 URL 权限映射。
这样设计的好处是:权限变更时不需要重建 SecurityManager,也不需要改动 FilterChain 定义,更不会导致用户 Session 失效。FilterChain 里始终只有三个固定过滤器(authc、dynamicPerms、logout),变的只是 dynamicPerms 内部持有的那份数据快照。
3.3 权限变更后的“冷热链路”同步
权限变更后的同步,我总结为“热链路即时刷新、冷链路缓存兜底”两条线。
热链路指的是 URL 级权限映射:管理员改了某个资源对应的 URL 权限,发布一条权限变更事件,所有后端节点收到事件后调用 dynamicPermsFilter 的 reload 方法,内存里的映射就更新了。冷链路指的是用户的授权集合缓存:某个用户的角色变了,角色下挂的权限码变了,需要把该用户的授权缓存删掉,下次请求重新加载。
单机部署时事件用 Spring 自带的事件机制就够了:事务提交后发布事件,监听器里清缓存。多机部署时我会用 Redis pub/sub 或者消息队列做广播,各节点只清理本机缓存的指定 key,不碰别人的缓存。详细的广播方案在第 6 章展开。
4. 关键代码落地:Realm、权限解析器与注解体系
4.1 ShiroConfig 配置与 Spring Boot 融合
先把 Shiro 的依赖引入项目。我用的是 shiro-spring-boot-web-starter,版本 1.9.0,它会把 ShiroFilter 自动注册到 Servlet 容器,省掉不少手动配置:
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-web-starter</artifactId> <version>1.9.0</version> </dependency>然后在 ShiroConfig 里配置 SecurityManager 和 FilterChain。注意一点:我没有在 FilterChainDefinitionMap 里写死几百条 URL 权限规则,因为那是静态配置,改了还得重启。我只配置固定入口,把具体的动态映射交给 DynamicUrlPermsFilter:
@Configuration public class ShiroConfig { @Bean public Realm myRealm(PermissionService permissionService) { MyRealm realm = new MyRealm(permissionService); realm.setAuthorizationCachingEnabled(true); realm.setAuthorizationCacheName("authorizationCache"); realm.setAuthenticationCachingEnabled(false); return realm; } @Bean public SecurityManager securityManager(Realm realm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(realm); securityManager.setRememberMeManager(null); return securityManager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, DynamicUrlPermsFilter dynamicUrlPermsFilter) { ShiroFilterFactoryBean factory = new ShiroFilterFactoryBean(); factory.setSecurityManager(securityManager); factory.setLoginUrl("/login"); Map<String, Filter> filters = new HashMap<>(); filters.put("authc", new UserAuthFilter()); filters.put("dynamicPerms", dynamicUrlPermsFilter); factory.setFilters(filters); LinkedHashMap<String, String> chain = new LinkedHashMap<>(); chain.put("/login", "anon"); chain.put("/logout", "logout"); chain.put("/api/**", "authc, dynamicPerms"); factory.setFilterChainDefinitionMap(chain); return factory; } @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } }这段配置里最关键的是 AuthorizationAttributeSourceAdvisor。Shiro 的方法注解(如 @RequiresPermissions)是通过 Spring AOP 实现的,没有这个 Advisor,注解永远不会被识别。这是新手最容易踩的第一个坑,后面第 7 章细说。
4.2 自定义 Realm 和授权缓存
Realm 是 Shiro 连接数据源的桥梁。认证方法从用户表里查账号密码,授权方法从缓存和数据库加载当前用户的角色码、权限码集合:
public class MyRealm extends AuthorizingRealm { private final PermissionService permissionService; public MyRealm(PermissionService permissionService) { this.permissionService = permissionService; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken upToken = (UsernamePasswordToken) token; SysUser user = permissionService.findByUsername(upToken.getUsername()); if (user == null || user.getStatus() != 1) { throw new UnknownAccountException("账号不存在或已禁用"); } return new SimpleAuthenticationInfo(user.getId(), user.getPassword(), getName()); } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Long userId = (Long) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setRoles(permissionService.getRoleCodesByUserId(userId)); info.setStringPermissions(permissionService.getPermCodesByUserId(userId)); return info; } }这里有一个容易被忽略的点:doGetAuthenticationInfo里返回的 principal 我用了 userId 而不是 username。原因是权限集合的缓存 key 必须稳定,如果拿 username 做 key,用户改一次名字,缓存和 Session 里的 principal 就全对不上了,还得专门做一次迁移。用 userId 就不会有这个问题。
授权缓存开启后,每次权限变更时就需要精确删除对应用户的缓存 key。我封装了一个方法,内部先清理 Redis key,再清理本机 Caffeine:
public void evictUserAuthorization(Long userId) { String cacheKey = "shiro:authorization:" + userId; redisTemplate.delete(cacheKey); caffeineCache.invalidate(cacheKey); }4.3 让通配符语义符合业务习惯的 PermissionResolver
Shiro 默认的通配符权限逻辑有一个让业务方很难接受的点:用户拥有order:list:view权限时,能匹配到order:list:view,但匹配不了order:list:view:dept;反过来,用户拥有order:list:view:dept时,也匹配不了order:list:view。
这在细粒度权限场景下非常要命。因为列表接口的注解可能只写了@RequiresPermissions("order:list:view"),但用户持有的是order:list:view:dept,正常情况下应该放行,默认逻辑却不放行。必须自定义 PermissionResolver:
public class CustomPermissionResolver implements PermissionResolver { @Override public Permission resolvePermission(String permissionString) { return new CustomWildcardPermission(permissionString); } }核心逻辑在 CustomWildcardPermission 的 implies 方法里。我把它简化为“拥有更具体的权限,则隐式拥有其所有前缀的权限”:用户权限是order:list:view:dept,那么他可以访问order:list:view和order:list。反过来不成立,拥有order:list:view不能访问order:list:view:dept,因为数据范围是向下收紧的,宽权限不能覆盖窄权限。
这个语义设计跟业务直觉完全一致:给区域经理配了order:list:view:dept,他就能看到订单列表,但数据范围被限制在本部门;给他配了order:list:view:all,所有订单都能看。权限码越长越具体,越具体的数据权限越小,但访问入口始终是同一个。
4.4 方法级细粒度控制:注解 + AOP
操作级权限直接用@RequiresPermissions标注在 Controller 方法上:
@RestController @RequestMapping("/api/order") public class OrderController { @GetMapping("/page") @RequiresPermissions("order:list:view") public Result<PageResult<OrderVO>> page(OrderQuery query) { return Result.ok(orderService.page(query)); } @PostMapping("/export") @RequiresPermissions("order:export:run") public Result<Void> export(@RequestBody ExportQuery query) { orderService.export(query); return Result.ok(); } @PostMapping("/delete/{id}") @RequiresPermissions("order:delete:run") public Result<Void> delete(@PathVariable Long id) { orderService.delete(id); return Result.ok(); } }但注解里的权限码是静态的,它表达不了“这个接口的数据范围是 dept 还是 all”。所以我又定义了一个 @DataScope 注解,用来标记哪些接口需要做数据范围切面:
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { String value(); }然后写一个 AOP 切面:方法执行前,根据注解里的资源码,解析当前用户在这个资源上的数据范围,放到 ThreadLocal 的上下文里,方法执行完后再清理。这样 Service 层可以随时从上下文拿到数据范围条件,而不需要每个方法都手动传参。这个设计直接支撑了下一章的数据级权限落地。
5. 数据级与字段级权限的实战写法
5.1 从“操作权限”到“数据范围”:scope 是如何被传递的
数据权限的核心,是在用户查询数据时,往 SQL 里无声无息地拼上一段“你得只能看这些数据”的条件。
我用 @DataScope 注解在 Controller 方法上标记后,切面解析出当前用户在当前资源上的数据范围(all、dept、self、custom),封装成一个 DataScope 对象放进 ThreadLocal。之后 Service 拿到的就是现成的 scope,把它翻译成 SQL 条件:
| 数据范围 | 生成的条件片段 |
|---|---|
| all | 不加条件,查全部 |
| self | AND t.owner_user_id = #{currentUserId} |
| dept | AND t.dept_id IN (查询本部门及所有子部门) |
| custom | AND t.dept_id IN (#{customDeptIds}) |
dept 范围要查出本部门及下级部门,通常会先根据当前用户的 dept_id 递归从组织架构表里查出所有子部门 ID 集合,再拼成IN条件。这个递归查询我建议放缓存里,组织架构变化不频繁,没必要每次查询都递归一次数据库。
5.2 推荐用参数化方式拼接数据范围条件
实现数据范围有一种很常见的方式是写 MyBatis 拦截器,拦截所有 select 语句,用正则或 AST 去改写 SQL,自动拼 where 条件。我一开始也想这么干,后来遇到几个多表 join 和子查询的场景,拦截器改写出来的 SQL 要么查错数据,要么直接报语法错误,排障特别耗时。最后我放弃了全局改写方案,改用参数化拼接。
所谓参数化拼接,就是 Service 层把 scope 转成数据条件对象,通过 Mapper 方法的参数传给 SQL,由 MyBatis 的 XML 动态拼接:
public PageResult<OrderVO> page(OrderQuery query) { DataScope scope = DataScopeContext.get(); query.setDataScopeSql(scope.extractCondition("o")); IPage<OrderDO> page = new Page<>(query.getPageNum(), query.getPageSize()); IPage<OrderVO> result = orderMapper.selectOrderPage(page, query); List<OrderVO> masked = FieldMaskUtil.maskFieldByPermission(result.getRecords(), permissionService.getFieldPermCodes("order")); return PageResult.of(masked, page.getTotal()); }对应的 Mapper XML:
<select id="selectOrderPage" resultType="OrderVO"> SELECT o.id, o.order_no, o.customer_name, o.customer_mobile, o.dept_id, o.owner_user_id FROM biz_order o <where> <if test="query.dataScopeSql != null and query.dataScopeSql != ''"> ${query.dataScopeSql} </if> <if test="query.status != null"> AND o.status = #{query.status} </if> </where> ORDER BY o.create_time DESC </select>注意 dataScopeSql 用的是${},这里存在 SQL 注入风险,所以条件拼接必须由我们的 DataScope 对象内部完全控制,值参数直接拼的是 SQL 片段常量,不掺外部输入。这也是为什么我要把整个过程封装成专门的服务,而不是让业务开发自己拼字符串。
这种参数化方案看起来不如拦截器“自动”,但可控性和可维护性高得多。数据权限的 SQL 条件就在 XML 显眼位置,谁都能看懂,出了问题也能快速定位。
5.3 字段级权限与脱敏:同一份接口不同人看到不同结果
字段级权限的实现,简单说就是“查到完整数据,按权限裁剪后再返回”。权限码基础上,我们把资源树里的字段资源(res_type=4)全部查出来,得到当前用户可看的字段集合,然后对敏感字段执行脱敏或置空。
脱敏规则我用了一个简单的策略枚举:
| 字段 | 无权限时的展示策略 |
|---|---|
| customer_mobile | 脱敏,显示 138****1234 |
| customer_name | 显示“张*” |
| cost_price | 置空,不传输 |
| supplier_name | 置空 |
| remark | 正常展示 |
实现上不搞花活,直接用一个工具类在 Service 里做 BeanMap 的映射转换,针对 String 字段做掩码,针对金额字段置 null。缺点是这样每个接口都要写一行工具调用,但优点是性能高、逻辑透明。如果业务里字段权限的接口特别多,可以做成 Jackson 序列化器统一处理,但要注意批量导出时也要走同一套逻辑,不能只处理了在线接口,导出的 Excel 里又是全量明文。
这个坑我在实际项目中遇到过:列表接口字段权限做好了,结果导出接口没人改,导出的 Excel 里客户手机号全是明文。最后只能赶工在导出服务里又做了一遍字段裁剪。所以字段权限一定要沉淀成一个公共入口,查询、导出、详情、报表,所有涉及字段展示的地方都要走同一个校验方法。
6. 权限管理后台:把授权从“改代码”变成“点配置”
6.1 后台功能清单与关键交互设计
前面所有机制都在为管理后台服务。后台界面上,我需要提供给管理员五个核心功能:菜单和资源管理、角色管理、用户角色分配、角色授权、权限变更日志。
资源管理是把 sys_res 表做成一个可维护的树形结构,CRUD 都不复杂。角色授权页是最关键的交互,我采用左右布局:左边是权限资源树,按菜单、按钮、接口、字段四个类型分组展示;右边是数据范围配置区。管理员勾选某个资源后,右边弹出数据范围下拉框,选择 all、dept、self、custom 四种之一。
这种交互的好处是,数据范围跟在具体资源上,而不是跟在整个角色上,真正实现了“同一个角色,查订单能看到全部,查客户只能看自己名下”这种差异化授权。
6.2 权限变更如何精确通知到所有在线用户
后台提交授权变更后,需要让新权限尽快生效。这个通知链路我设计成三步:
第一步,保存变更记录到数据库,放在一个事务里,保证授权数据本身是可靠的。第二步,事务提交成功后发布一个PermissionChangeEvent,事件里带着受影响的 userId 集合和变更的权限码集合。第三步,监听器里做两件事:一是清缓存,二是通知其他节点的 DynamicUrlPermsFilter 刷新 URL 映射。
多机部署时的广播我用过 Redis pub/sub。事件监听器发布一条指定 channel 的消息,所有节点订阅这个 channel,收到后各自执行本地缓存的精确删除和 URL 映射刷新。因为事件带了 userId,所以每个节点只要删本机上这几个 userId 的授权缓存就行,不用把整个缓存轰掉。
这里最忌讳的是图省事,在权限变更后直接把所有用户的授权缓存全部清空。系统大了以后,cache miss 会瞬间全部打到数据库上,接口响应时间直接飙到几百毫秒。只清受影响用户的缓存看起来代码多一些,但对生产环境友好得多。
7. 生产环境踩过的坑与解决过程
7.1 注解不生效:Spring AOP 代理问题的排查链路
第一次联调时发现,给接口加了 @RequiresPermissions 注解,权限校验完全不生效,没有对应权限的用户照样能调接口。现象是方法能被正常调用,但 Shiro 的拦截逻辑完全没有介入。
排查过程先从最简单的原因开始。第一步检查 shiro-spring-boot-web-starter 是否引入了 spring-boot-starter-aop,因为 Shiro 的方法注解依赖 AOP 代理。确认依赖没问题后,第二步检查 Controller 是否被 Spring 容器正常代理。这里有一个很隐蔽的点:Controller 如果被 private 方法拦截目标,或者类没有被 Spring 扫描到,代理就不会生效。
最终定位到的问题是 AuthorizationAttributeSourceAdvisor 没有注册。Shiro 的注解是通过这个 Advisor 生成的 AOP 切面来执行的,缺少它,注解就是摆设。加上 4.1 节里的 advisor 配置后问题解决。这个坑的教训是,第一次搭 Shiro + SpringBoot 项目,先把官方文档里 AOP 相关配置一字不差跑通,再去做自定义扩展,否则你分不清是自定义代码的问题还是基础配置的问题。
7.2 动态 URL 权限更新把 Session 搞丢了
刚开始做动态 URL 权限时,我图省事,权限变更后直接把 SecurityManager 重新初始化一遍,再重建 FilterChain。结果一上线就出问题:管理员在后台改了一条权限规则,所有在线用户的 Session 全部失效,全员被迫重新登录,用户投诉电话直接打到项目经理那里。
排查链路很明确:复现问题后看前后端日志,发现权限变更的那一刻,服务端输出了一堆 Session 销毁日志。再查代码,发现重建 SecurityManager 时会创建新的 DefaultSessionManager,旧 Session 管理器被替换,原有的 Session 自然全部失效。
解决方案前面已经讲了,就是不再重建 SecurityManager,也不改 FilterChain 的固定定义,而是把动态部分收敛到 DynamicUrlPermsFilter 内部的数据快照上。权限变更只触发一次内存 map 的替换,Session 管理器从头到尾都没有动过,这个问题再没出现过。
7.3 事务与缓存时序:先改库还是先清缓存
权限变更后缓存一直出现“旧数据被重新加载”的问题。具体现象是:用户把某个角色的权限改小了,但被改的角色用户在短时间里依然能访问旧权限。我一开始是先清 Redis 缓存再去更新数据库权限,恰好有一次更新数据库时报错事务回滚了,缓存却已经被清了,后续请求又把旧权限加载回缓存,新权限反而一直没生效。
正确的顺序是:先数据库事务提交成功,再发布事件清缓存。如果事务失败,缓存根本不应该被清,下一次加载的还是旧数据,虽然用户暂时看不到最新的权限,但至少系统是一致的。为了保证这个顺序,我用 Spring 的@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)来监听事务提交事件,事务没提交就不响应对应的缓存清理逻辑。
7.4 跨域预检请求被 Shiro 拦住
项目前端和后端分开部署后,浏览器跨域调接口报错,打开控制台发现 OPTIONS 预检请求返回 401。原因是 Shiro 过滤器链执行顺序在全局 CORS 过滤器之前,预检请求还没到 Spring MVC 就被 Shiro 当成未认证请求拦掉了。
解决方法是把网关或后端入口处的 CORS 过滤器执行顺序调到 Shiro 过滤器之前。具体做法是自定义 CorsFilter 并设置@Order(Ordered.HIGHEST_PRECEDENCE),同时在 Shiro 过滤链的最前面放行 OPTIONS 请求:
chain.put("/**", "anon");这一行不会影响认证安全性。实际上我在过滤器链里配置成所有/api/**都先过动态权限过滤器,OPTIONS 请求在 DynamicUrlPermsFilter 里直接放行,不带任何业务操作的非数据类请求不会触发权限校验:
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (isPreFlightRequest(request)) { return true; } return super.isAccessAllowed(request, response, mappedValue); }7.5 权限变更日志与权限码混乱的治理
最后说一个不算坑但很影响长期维护的问题:权限码如果不做治理,很快就会变得又臭又乱。业务开发时常见做法是随手在注解里写一个权限码,结果 sys_res 表里出现大量名字相似但功能重复的权限码,管理员授权时根本分不清该勾哪个。
我个人后来定了一套规矩:新增资源前,先在 sys_res 表里按模块登记权限码;登记时必须写明权限码全称(四段式),并按菜单、按钮、接口、字段分类;代码里的 @RequiresPermissions 注解引用的权限码必须先查 sys_res 表是否存在,确保不引入脏码。权限码一旦上线就尽量不要改字符串,改名的代价不只是替换代码,还包括历史角色的授权数据关联。
这个规矩推行之后,权限模块的返工率明显下降。系统的核心价值也稳定了下来:资源树越来越规范、权限码越来越清晰、数据范围配置越来越精准,权限管理终于从“开发写死、上线返工”变成了“运营配置、即时生效”。
代码层面最后再做一个小优化:权限校验集中到一个工具方法里,业务接口优先用注解声明,但有特殊场景(比如一样的接口按参数不同走不同权限)时,可以随时在 Service 里调用工具方法按权限码做判断。这套机制用到现在,系统里几百个接口,权限逻辑依然干净,没有出现哪个接口悄悄绕过校验的漏洞。