AOP加SpEL实现动态数据权限

1. 对应简历段落

这篇文章对应简历中“基于 Spring AOP + SpEL 实现动态数据权限控制,统一处理机构、渠道、角色和业务归属范围”的项目经历。简历可以写成:

设计动态数据权限框架,基于自定义注解、Spring AOP 和 SpEL 表达式解析用户上下文与业务参数,生成机构、渠道、岗位、客户经理等维度的数据范围,并与 MyBatis 查询条件和审计日志集成,解决保险运营后台多角色数据隔离问题。

面试官通常会追问:为什么不用硬编码 if else?AOP 拦截在哪一层?SpEL 怎么取方法参数?权限上下文如何传递到 MyBatis?如何避免越权?如何处理管理员、机构负责人、普通坐席、渠道人员的不同范围?权限表达式会不会有性能问题?如果一个接口查列表和导出都要控制,如何保证一致?

回答时要强调:数据权限不是简单的菜单权限。菜单权限决定“能不能访问功能”,数据权限决定“能看到哪些数据”。在保险业务里,同一个保单列表,不同角色看到的数据范围完全不同,且范围可能由登录人机构、岗位、渠道、保单归属、客户经理、产品线共同决定。动态数据权限的价值,是把这些规则从业务 SQL 和 Controller 中抽离出来,形成可复用、可审计、可测试的横切能力。

2. 业务背景

保险运营系统中,数据权限是非常典型的复杂场景。总部运营可以查看全国数据;分公司经理只能查看本机构及下级机构;渠道经理只能看自己负责渠道的保单;客服坐席只能看分配给自己的回访工单;代理人只能看自己名下客户;风控人员可能按产品线或风险等级查看;外包团队还要屏蔽敏感字段。

如果每个查询都在 SQL 中手写权限条件,系统很快会失控。第一,规则重复。客户列表、保单列表、订单列表、导出接口、统计接口都要写类似机构条件。第二,容易漏控。新接口上线时开发者可能忘记追加权限条件,造成越权。第三,难维护。组织架构、角色模型、渠道关系变化后,需要改大量 SQL。第四,难审计。安全团队很难确认某个接口到底应用了哪些数据范围。第五,测试成本高。不同角色、机构、渠道组合多,靠人工点页面容易漏。

因此项目中引入 AOP + SpEL。AOP 负责在业务方法执行前统一识别需要数据权限的场景;注解描述权限类型、参数来源和策略;SpEL 从方法参数、登录上下文或业务对象中动态取值;权限服务根据规则计算数据范围;最终通过 MyBatis 参数、ThreadLocal 上下文或插件注入 SQL 条件。这样,业务方法保持清晰,权限规则集中治理。

3. 核心原理

Spring AOP 基于代理实现,适合拦截 Spring Bean 的方法调用。数据权限通常放在 Service 层或 Mapper 层之前。放在 Controller 层可以拿到请求信息,但容易和业务参数转换耦合;放在 Mapper 层接近 SQL,但缺少业务语义;放在 Service 层通常较平衡,因为它既有业务参数,又能覆盖列表、详情、导出和统计等入口。

自定义注解用于描述权限需求。例如 @DataScope(resource = "policy", key = "#query.orgCode", mode = ORG_AND_CHANNEL) 表示当前方法查询保单资源,机构参数从 query.orgCode 取,权限模式是机构加渠道。注解不要承载过多业务逻辑,否则会变成另一套难懂的 DSL。复杂规则应该由权限服务实现。

SpEL 用于动态解析方法参数。AOP 可以通过 MethodSignature 获取参数名和参数值,构造 EvaluationContext,把参数、当前用户、当前机构、请求信息放进去,再解析注解中的表达式。比如 #query.policyNo 取查询对象中的保单号,#user.orgCode 取当前登录人机构,#root.args[0] 取第一个参数。

权限上下文用于在执行链路中传递数据范围。常见做法有三种:第一,把权限范围写回查询对象,例如设置 allowedOrgCodesallowedChannelCodes;第二,使用 ThreadLocal 保存 DataScopeContext,由 MyBatis 插件读取并注入 SQL;第三,业务方法显式调用权限服务返回条件。第一种直观、可测试,但需要查询对象有统一字段;第二种侵入少,但要防止 ThreadLocal 泄漏;第三种最显式,但重复代码较多。

最终 SQL 注入要谨慎。数据权限条件可以由业务 XML 显式引用,例如 <foreach collection="allowedOrgCodes">;也可以由 MyBatis 插件自动拼接。自动拼接能力强,但 SQL 解析和别名处理复杂。遗留系统中更稳妥的方式是 AOP 计算权限范围,写入查询参数,XML 使用统一片段拼接。

4. 项目落地

落地第一步是梳理权限模型。不要一上来写框架,而要先列出资源类型、角色类型和范围维度。保险系统常见资源包括保单、客户、订单、回访工单、渠道、佣金、报表。范围维度包括机构树、渠道树、用户归属、岗位、产品线、业务状态和敏感字段。每个接口要明确数据主表和归属字段,例如保单用 manage_org_code,客户用 owner_user_id,渠道用 channel_code

第二步是设计注解。注解保持简单,描述资源、策略、参数表达式、是否允许空范围、是否跳过管理员。例如:

@DataScope(resource = "policy", strategy = DataScopeStrategy.ORG_TREE,
    target = "#query", denyIfEmpty = true)
public PageResult<PolicyVO> queryPolicies(PolicyQuery query) {
    return policyMapper.queryPolicies(query);
}

第三步是实现切面。切面解析注解和 SpEL,获取当前登录用户,调用权限服务计算范围,再把结果绑定到查询对象或上下文。切面必须使用 try-finally 清理 ThreadLocal,并记录审计日志,包括用户、资源、策略、范围大小和接口。

第四步是与 MyBatis 集成。推荐先采用查询对象字段方式,例如所有继承 DataScopeAware 的查询对象都有 dataScope 属性。XML 中通过公共 SQL 片段追加:

<if test="dataScope != null and dataScope.orgCodes != null">
  and p.manage_org_code in
  <foreach collection="dataScope.orgCodes" item="org" open="(" separator="," close=")">
    #{org}
  </foreach>
</if>

第五步是做安全兜底。没有权限范围时默认拒绝,而不是默认查全部。只有明确标记超级管理员或系统任务,才能跳过数据权限。导出接口、统计接口、详情接口也要纳入同一套注解,避免列表有权限、导出无权限。

5. 关键代码或流程

注解定义:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
    String resource();
    DataScopeStrategy strategy();
    String target() default "";
    boolean denyIfEmpty() default true;
}

切面核心逻辑:

@Aspect
@Component
public class DataScopeAspect {

    private final ExpressionParser parser = new SpelExpressionParser();
    private final DataScopeService dataScopeService;

    @Around("@annotation(dataScope)")
    public Object around(ProceedingJoinPoint pjp, DataScope dataScope) throws Throwable {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        EvaluationContext context = buildContext(signature, pjp.getArgs());
        UserContext user = UserContextHolder.current();
        context.setVariable("user", user);

        Object target = null;
        if (!dataScope.target().isEmpty()) {
            target = parser.parseExpression(dataScope.target()).getValue(context);
        }

        DataScopeRule rule = dataScopeService.resolve(user, dataScope.resource(), dataScope.strategy());
        if (dataScope.denyIfEmpty() && rule.isEmpty()) {
            throw new AccessDeniedException("No data scope for resource " + dataScope.resource());
        }

        try {
            DataScopeBinder.bind(target, rule);
            DataScopeContextHolder.set(rule);
            return pjp.proceed();
        } finally {
            DataScopeContextHolder.clear();
        }
    }
}

流程可以概括为:接口进入 Service 方法、AOP 识别 @DataScope、SpEL 解析查询对象、读取登录用户、权限服务计算范围、绑定到查询参数、Mapper SQL 使用范围条件、记录审计日志、方法结束清理上下文。

权限服务计算示例:总部角色返回全部机构但仍标记为受控;分公司角色查询组织树获得本机构及下级机构;渠道角色查询负责渠道列表;坐席角色返回本人 userId;多角色用户取并集还是交集要根据业务定义,不能开发者拍脑袋决定。

6. 常见坑

第一个坑是空权限默认放行。安全系统应该默认拒绝,除非明确配置跳过,否则一旦组织关系查询异常就可能全表可见。

第二个坑是只控制列表不控制详情。用户可以通过详情接口直接传 id 越权查看数据,所以详情、导出、统计都要控制。

第三个坑是 ThreadLocal 不清理。线程池复用会导致权限上下文串到下一个请求,这是严重问题。

第四个坑是 SpEL 表达式过度复杂。表达式应该只负责取值,不应该写复杂业务规则。复杂逻辑放到 Java 权限服务中。

第五个坑是机构列表过大。in 条件几千个机构会影响 SQL 性能,可以用机构路径、临时表、权限关系表或数据冗余字段优化。

第六个坑是别名不统一。如果自动 SQL 拼接依赖表别名,而各 XML 别名不同,插件会非常脆弱。显式 XML 片段更稳。

7. 面试追问

  1. 数据权限和功能权限有什么区别?
  2. 为什么选择 AOP + SpEL,而不是每个 SQL 手写?
  3. AOP 放在 Controller、Service、Mapper 哪一层更合适?
  4. SpEL 如何获取方法参数名?
  5. 权限范围如何传递给 MyBatis?
  6. ThreadLocal 方案有什么风险?
  7. 空权限应该放行还是拒绝?
  8. 详情接口如何防止按 id 越权?
  9. 大机构树 in 查询性能差怎么优化?
  10. 如何审计某次查询应用了哪些数据范围?

8. 推荐回答

如果问“为什么用 AOP + SpEL 实现数据权限”,可以回答:

因为数据权限是横切能力,散落在每个 Controller 或 Mapper 里会重复且容易漏。AOP 可以统一拦截需要权限控制的 Service 方法,注解描述资源和策略,SpEL 从方法参数和用户上下文中取值,权限服务计算机构、渠道、用户等范围,再绑定到查询参数或 MyBatis 上下文。这样列表、详情、导出、统计可以复用同一套规则,也便于审计。

如果问“如何避免越权”,可以回答:

第一是默认拒绝,权限范围为空不能查全部;第二是所有入口统一加注解,尤其是详情和导出;第三是排序、字段、机构等动态条件都做白名单;第四是审计日志记录用户、资源、策略和范围;第五是测试覆盖不同角色和机构组合。对超级管理员也要显式标记,不能靠空条件代表全量。

如果问“权限范围怎么落到 SQL”,可以回答:

我倾向于 AOP 计算范围后写入统一查询对象,例如 dataScope.orgCodeschannelCodesownerUserIds,XML 通过公共片段拼接。这样比插件自动解析 SQL 更稳,表别名和复杂 SQL 可控。对于新系统也可以用 MyBatis 插件统一注入,但要解决 SQL 解析、别名识别和插件顺序问题。

9. 延伸学习路线

第一阶段学习 Spring AOP,理解 JDK 代理、CGLIB、切点、通知、代理失效和自调用问题。

第二阶段学习 SpEL,掌握参数上下文、Bean 调用、类型转换、表达式缓存和安全边界。

第三阶段学习权限模型设计,包括 RBAC、ABAC、组织树、渠道树、岗位、数据归属和审计。

第四阶段学习 MyBatis 动态 SQL 和插件,理解数据权限条件如何进入 SQL。

第五阶段学习安全测试方法,覆盖越权、空权限、导出绕过、详情绕过、缓存污染和日志审计。资深回答要把技术实现和安全原则一起讲清楚。