Spring AOP + 自定义注解实现三角色权限控制:从设计到落地的完整方案

老炮踩坑录 · D01 · 技术深挖系列

· 基于「企业融合评估系统」真实源码

· 没有用 Spring Security,没有用 Shiro,3个空注解 + 1个切面类,搞定三角色的接口访问控制。这篇文章,从设计思路到每一行代码,完整拆解。


引子

您好,文接上篇,见字如面。

用Java做鉴权,大多数人的第一反应是 Spring Security 或 Shiro。

但这个项目没有用任何一个。原因很简单------杀鸡不用牛刀

一个政府类平台,三个角色(企业管理员、职能人员、后台管理员),接口不多,业务不复杂。引入 Spring Security 的过滤器链,配置量比业务代码都多,维护成本太高。

因此选择了另一条路:自定义注解 + AOP 切面

最终效果:

  • 3个注解,每个注解只有3行代码,什么都不干,只是"贴标签"
  • 1个切面类,不到300行,包含全部鉴权逻辑
  • 零侵入:Controller 方法上加个注解就行,不用写一行鉴权代码

这篇文章,我们就从设计到落地,做个完整复盘。


方案架构

第一步:设计注解

为什么是"空注解"?

很多初学者写注解,喜欢在里面定义一些属性:

java 复制代码
// 过度设计
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCompany {
    String role() default "admin";
    boolean requireLogin() default true;
    String permission() default "";
}

这个项目反其道而行之------注解里什么都不放

java 复制代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCompany {
}

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthDept {
}

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthBack {
}

设计原则:注解只做"标记",不做"配置"。

为什么?

因为鉴权逻辑是统一的------所有企业入口都一样:检查Session、检查Cache、检查账户变更。不需要通过注解属性来区分。注解的唯一作用是告诉切面:"这个方法需要企业角色鉴权"。

这就是设计模式中的标记注解(Marker Annotation) ------Java 内置的 @Override 也是这个思路。

两个元注解的选择

元注解 选择 原因
@Target METHOD 鉴权粒度是"方法级",不是"类级"。同一个Controller中,不同方法可能对应不同角色
@Retention RUNTIME 必须在运行时通过反射读取。SOURCECLASS 阶段拿不到

第二步:设计切面

切点表达式

java 复制代码
@Pointcut("execution(public * com.xiaomayi.jser.*.controller.*.*(..))")
public void authPointcut() {}

解读:

部分 含义
execution 方法执行级别的连接点
public 只拦截 public 方法(private/protected 不管)
com.xiaomayi.jser.*.controller.* 所有模块下的 controller 包中的所有类
.*(..) 所有方法,任意参数

为什么这样设计?

  • 按包名拦截,而不是按注解拦截。先拦截所有Controller方法,再在方法内部判断有没有注解。
  • 好处:切点表达式简单,不会因为漏配而漏掉接口。
  • 代价:每个public方法都会进入切面,即使没有注解。但这个开销可以忽略(一次注解读取,纳秒级)。

环绕通知的整体结构

java 复制代码
@Around("authPointcut()")
public Object authPointcut(ProceedingJoinPoint pjp) {
    try {
        // ① 获取当前方法对象
        Method currentMethod = ...;
        
        // ② 读取方法上的三个注解
        AuthCompany annoCompany = currentMethod.getAnnotation(AuthCompany.class);
        AuthDept annoDept = currentMethod.getAnnotation(AuthDept.class);
        AuthBack annoBack = currentMethod.getAnnotation(AuthBack.class);
        
        // ③ 按注解类型做鉴权
        if (annoCompany != null) { /* 企业鉴权 */ }
        if (annoDept != null)    { /* 职能人员鉴权 */ }
        if (annoBack != null)    { /* 后台管理员鉴权 */ }
        
        // ④ 鉴权通过,执行业务方法
        return pjp.proceed();
    } catch (SystemException e) {
        // ⑤ 统一异常处理
        return ...;
    } 
}

关键设计:一个 @Around 只做了一件事------鉴权

第三步:企业鉴权的完整链路

@AuthCompany 为例,完整的企业鉴权逻辑:

java 复制代码
if (annoAuthCompany != null) {
    // 第一关:Session 中有企业信息吗?
    if (session.getAttribute(SESSION_COMPANY_TYPE) == null) {
        // Session 没有 → 尝试从 Cookie + Cache 恢复
        resetSessionInfo(request);
    }
    // 第二关:恢复后还是没有?
    if (session.getAttribute(SESSION_COMPANY_TYPE) == null) {
        // 真的没登录 → 返回未登录
        return new Result<>().fail(ResultEnum.NO_LOGIN);
    }
}
// 通过 → 执行 pjp.proceed()

两次检查的设计意图:

这个"两次检查"的模式,是Session容灾恢复的核心。

第四步:登录写入------三处存储

登录成功后,用户信息写入三个地方:

Session(主存储)

java 复制代码
// LoginServiceImpl.extLoginInfo()
session.setAttribute(SESSION_TOKEN, accessToken);
session.setAttribute(SESSION_COMPANY_USER_INFO, userInfo);
session.setAttribute(SESSION_COMPANY_TYPE, LOGIN_USER_TYPE_COMPANY);
session.setAttribute(SESSION_COMPANY_INFO, companyData);
session.setAttribute(SESSION_COLUMN_NAME_USER_ID, userId);
session.setAttribute(SESSION_COLUMN_NAME_ENTERPRISEID, enterpriseId);
session.setAttribute(SESSION_COLUMN_NAME_AREA_CODE, areaCode);

写入 7个属性,覆盖用户ID、企业信息、地区编码等。

Cookie(凭证)

java 复制代码
// LoginServiceImpl.extLoginInfo()
Cookie cookie = new Cookie("COMPANY_TOKEN", token);
cookie.setMaxAge(-1);  // 浏览器关闭时失效
cookie.setPath("/");
response.addCookie(cookie);

Cookie 只存一个值:Token。不存用户信息(安全考虑)。

Guava Cache(备份)

java 复制代码
// AuthAspect.cacheUserInfo()
public static void cacheUserInfo(JSONObject userInfo) {
    AuthAspect.CACHES.put(userInfo.getString("token"), userInfo);
}

Cache 存储的映射关系:Token → 完整的用户信息JSON

Cache 配置

java 复制代码
private static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()
    .maximumSize(50)                        // 最多50个Token
    .expireAfterWrite(30, TimeUnit.MINUTES) // 30分钟过期
    .build();

这里有一个坑:maximumSize=50。 同时在线用户超过50个,最早的Token会被挤出缓存。

第五步:Session恢复------从Cookie中"复活"

这是整个鉴权方案最精妙的部分。

诡异场景:

​ 用户明明还在操作,突然 Session 莫名其妙的丢了,然后跳转到登录页面。这是后台服务自动重启或者负载均衡切换导致。

这个方案就可以解决这种场景出现的问题。

java 复制代码
private void resetSessionInfo(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies != null) {
        for (Cookie cookie : cookies) {
            // 匹配三种Token
            if ("COMPANY_TOKEN".equalsIgnoreCase(cookie.getName())) {
                String uuid = cookie.getValue();
                // 用Token从Cache 中找回用户信息
                JSONObject cacheData = (JSONObject) CACHES.getIfPresent(uuid);
                if (cacheData == null){
                     continue;  // Cache也没有,恢复失败
                }
                
                // 恢复到 Session
                HttpSession session = request.getSession();
                session.setAttribute(SESSION_TOKEN, 
                    cacheData.getString(SESSION_TOKEN));
                session.setAttribute(SESSION_COMPANY_USER_INFO, 
                    cacheData.getJSONObject(SESSION_COMPANY_USER_INFO));
                session.setAttribute(SESSION_COMPANY_TYPE, 
                    cacheData.getIntValue(SESSION_COMPANY_TYPE));
                session.setAttribute(SESSION_COMPANY_INFO, 
                    cacheData.getJSONObject(SESSION_COMPANY_INFO));
                session.setAttribute(SESSION_COLUMN_NAME_USER_ID, 
                    cacheData.getString(SESSION_COLUMN_NAME_USER_ID));
                session.setAttribute(SESSION_COLUMN_NAME_ENTERPRISEID, 
                    cacheData.getString(SESSION_COLUMN_NAME_USER_ID));
                session.setAttribute(SESSION_COLUMN_NAME_AREA_CODE, 
                    cacheData.getString(SESSION_COLUMN_NAME_AREA_CODE));
            }
            // DEPT_TOKEN 和 BACK_TOKEN 同理
        }
    }
}

恢复链路:

第六步:账户变更实时检测

职能人员和后台管理员入口,除了检查登录态,还检查账户是否被管理员修改过

java 复制代码
if (annoAuthDept != null) {
    // ... 登录态检查 ...
    
    // 账户变更检测
    SysAccount deptUserInfo = SessionCacheUtils.getDeptUserInfo();
    if (isModifiedSysAccount(deptUserInfo)) {
        return this.returnObject(currentMethod, new Result<>().fail(ResultEnum.NO_LOGIN));
    }
}

isModifiedSysAccount() 的实现------每次请求都查DB比对

java 复制代码
private boolean isModifiedSysAccount(SysAccount userInfo) {
    if (userInfo == null){
        return true;   
    }
    Map qsaParams = new HashMap();
    qsaParams.put("id", userInfo.getId());
    List<SysAccount> dbAccounts = sysAccountMapper.queryByCond(qsaParams);
    
    if (dbAccounts.size() == 0) {
        return true;  // 账号被删了
    }
    return isModified(userInfo, dbAccounts.get(0));
}

比对 7个关键字段

java 复制代码
private boolean isModified(SysAccount session, SysAccount db) {
    if (!compareString(session.getAccount(), db.getAccount())) return true;     // 账号名
    if (!compareString(session.getPassword(), db.getPassword())) return true;   // 密码
    if (!compareString(session.getAccountname(), db.getAccountname())) return true; // 姓名
    if (!compareInteger(session.getAccountflag(), db.getAccountflag())) return true; // 类型
    if (!compareString(session.getTelphone(), db.getTelphone())) return true;   // 手机
    if (!compareInteger(session.getCitycode(), db.getCitycode())) return true;  // 城市
    if (!compareInteger(session.getAreacode(), db.getAreacode())) return true;  // 区县
    return false;
}

任何一个字段变了 → 立即踢出。 安全性拉满,但每次请求多一次DB查询。

第七步:多返回类型适配

这个设计也非常巧妙。

Controller 中不同方法的返回类型不一样------有的返回 Result,有的返回 Map,有的返回 String。但切面需要统一返回"未登录"的错误信息。

讨论

返回类型不一样,为何不统一呢?这个有历史原因。要对接其它三方系统,类型不由我们决定,只能适配。

returnObject() 方法解决了这个问题:

java 复制代码
private Object returnObject(Method currentMethod, Result fail) {
    Class<?> returnType = currentMethod.getReturnType();
    
    if (returnType.equals(Result.class)) {
        return fail;                              // 直接返回 Result
    } else if (returnType.equals(Map.class)) {
        Map<String, Object> map = new HashMap<>();
        map.put("code", fail.code);
        map.put("data", fail.data);
        map.put("msg", fail.msg);
        return map;                               // 转成 Map
    } else if (returnType.equals(String.class)) {
        return JSON.toJSONString(fail);           // 转成 JSON 字符串
    } else if (returnType.equals(List.class)) {
        List ret = new ArrayList();
        ret.add(fail);
        return ret;                               // 包装成 List
    } else if (returnType.equals(Object.class)) {
        return fail;                              // 兜底
    }
    return null;
}

设计思想:根据方法的声明返回类型,自动适配错误响应的格式。

这样前端不管用什么方式解析响应,都能正确拿到错误信息。


第八步:统一异常处理

@Around 的 catch 块同时承担了异常处理职责:

java 复制代码
} catch (SystemException | RapplyException | ElecException e) {
    log.error(e.getMessage(), e);
    if (e.getResultEnum() != null) {
        return this.returnObject(currentMethod, new Result<>().fail(e.getResultEnum()));
    } else {
        return this.returnObject(currentMethod, new Result<>().fail(e.getMessage()));
    }
} 

三个自定义异常(SystemException / RapplyException / ElecException)的 catch 逻辑完全一样。 这是一个可以优化的点------让三个异常实现同一个接口,一个 catch 就够了。

第九步:登出------三处清除

java 复制代码
public Result logout(Integer type, HttpSession session, HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies != null) {
        for (Cookie cookie : cookies) {
            cookie.setMaxAge(0);  // ① Cookie 立即失效
            if ("COMPANY_TOKEN".equalsIgnoreCase(cookie.getName())) {
                AuthAspect.clearCacheUserInfo(cookie.getValue());  // ② Cache 清除
                session.setAttribute(SESSION_COMPANY_TYPE, null);  // ③ Session 置空
                session.setAttribute(SESSION_COMPANY_USER_INFO, null);
                session.setAttribute(SESSION_COMPANY_INFO, null);
                // ... 共清除7个属性
            }
        }
    }
    return new Result().success(null);
}

登出三重清理:

scss 复制代码
① Cookie.setMaxAge(0)  → 浏览器删除 Cookie
② CACHES.invalidate()  → Guava Cache 删除映射
③ Session.setAttribute(null) → Session 清空用户信息

三处都清了,彻底干净。

完整数据流

%%{init: {'sequence': {'messageMargin': 35, 'boxMargin': 15, 'actorMargin': 50}}}%% sequenceDiagram autonumber actor User as 用户/客户端 participant LC as LoginController participant LS as LoginServiceImpl participant API as YYY云API participant AA as AuthAspect (切面) participant S as Session participant C as Cookie participant Cache as Guava Cache %% 在此处插入一个空注释行,用来撑开图例与下方区块的间距(<br><br>代表换行) Note over User, Cache: <br><br> %% 第一条线:登录 rect rgb(230, 242, 255) Note over User, Cache: 第一线:【登录】 User->>LC: 输入账号密码 LC->>LS: login() LS->>API: 调用YYY云API LS->>S: extLoginInfo() → 写入7个属性 LS->>C: extLoginInfo() → (&#34;COMPANY_TOKEN&#34;, uuid) LS->>Cache: extLoginInfo() → put(uuid, userInfo) end %% 第二条线:正常请求 rect rgb(230, 250, 230) Note over User, Cache: 第二线:【正常请求】 User->>AA: 发起请求 AA->>AA: 读 @AuthCompany 注解 AA->>S: 检查用户信息 S-->>AA: 有用户信息 AA->>AA: 账户变更检测 → 通过 AA-->>User: proceed() 放行 end %% 第三条线:Session丢失请求(含Token过期) rect rgb(255, 248, 225) Note over User, Cache: 第三线:【Session丢失请求】 User->>AA: 发起请求 AA->>AA: 读 @AuthCompany 注解 AA->>S: 检查用户信息 S-->>AA: 没有用户信息 AA->>AA: resetSessionInfo() AA->>C: 读 Cookie 的 Token AA->>Cache: 查 Cache alt Cache 找到 Token (恢复成功) Cache-->>AA: 找到用户 AA->>S: 恢复 Session AA-->>User: proceed() 放行 else Cache 没找到 (【Token也过期】) Cache-->>AA: 没找到 AA-->>User: 返回 NO_LOGIN end end %% 第四条线:登出 rect rgb(255, 230, 230) Note over User, Cache: 第四线:【登出】 User->>LC: 发起登出请求 logout() LC->>C: setMaxAge(0) LC->>Cache: invalidate(token) LC->>S: setAttribute(null) × 7 end

设计原则总结

设计决策 背后原则 说明
空注解 标记注解模式 注解只做标记,不做配置。逻辑集中在切面
按包名拦截 简单优于灵活 一个切点覆盖所有Controller,不用逐个配置
三处存储 防御性设计 Session/Cookie/Cache 互为备份
两次检查 优雅降级 先尝试恢复,恢复失败再踢出
多返回类型适配 适配器模式 根据方法签名自动选择错误响应格式
职责分离 单一职责 切面负责鉴权,Controller只管业务

优化改进

异常处理合并

三个 catch 逻辑完全相同,可以合并:

java 复制代码
// 让三个异常实现同一个接口
public interface BizException {
    ResultEnum getResultEnum();
}

// 一个 catch 搞定
} catch (BizException e) {
    log.error(e.getMessage(), e);
    ResultEnum re = e.getResultEnum();
    return this.returnObject(currentMethod, 
        re != null ? new Result<>().fail(re) : new Result<>().fail(e.getMessage()));
}

Cache 升级 Redis

java 复制代码
// 当前:Guava Cache(本地,50条上限)
Cache<String, Object> CACHES = CacheBuilder.newBuilder()
    .maximumSize(50)
    .expireAfterWrite(30, TimeUnit.MINUTES)
    .build();

// 改进:Redis(分布式,无上限)
@Autowired
private StringRedisTemplate redisTemplate;

// put: redisTemplate.opsForValue().set("AUTH:" + token, json, 30, MINUTES);
// get: redisTemplate.opsForValue().get("AUTH:" + token);
// del: redisTemplate.delete("AUTH:" + token);

账户变更检测改为事件驱动

java 复制代码
// 当前:每次请求查DB比对
if (isModifiedSysAccount(deptUserInfo)) { ... }

// 改进:管理员修改时主动失效Cache
// 管理员修改账户的 Service 方法中:
public void updateAccount(SysAccount account) {
    sysAccountMapper.update(account);
    AuthAspect.clearCacheUserInfo(account.getId());  // 主动失效
}

// 切面中只检查Cache:
if (CACHES.getIfPresent("VALID_" + userId) == null) {
    return NO_LOGIN;
}

老炮点评

老炮语录:

  1. 权限控制方案的好坏,不看用了什么技术,看用户能不能无感知。 ------Spring Security 很强大,但如果用户被踢到登录页骂娘,那就是失败的设计。

  2. "空注解是最好的注解。" ------注解里放属性,看似灵活,实则增加了理解成本。标记就是标记,干净利落。

  3. "AOP 切面是'隐式调用',写的时候爽,调试的时候哭。" ------代码里看不到调用关系,断点打在Controller里对不上执行顺序。解法:切面里加详细日志。

  4. "三处存储不是冗余,是容灾。" ------Session 会丢,Cookie 会过期,Cache 会溢出。三个都不可靠,但组合在一起,可靠性就上去了。

  5. "没有最好的鉴权方案,只有最合适的。" ------小项目用注解+AOP,中项目用拦截器+JWT,大项目用Spring Security。选型的关键是"匹配",不是"先进"。


下期预告:《POI 动态二级表头算法:并集 + 合并 + 冻结列的完整实现》

​ 这篇文章我们讲了上篇提到的3个亮点。下篇我们分析这个项目里让我折腾了三天的模块------POI 动态二级表头算法:并集 + 合并 + 冻结列优雅实现。一级表头不固定、二级表头要合并、还要冻结列,网上的方案没一个能用。下期我把完整算法拿出来做深入的分析。

我是老炮,18年奋斗在一线的Java老兵。这里记录真实项目里的踩坑经历。关注我,少走弯路。

相关推荐
Mikko71 小时前
jackson-databind 升到 2.21.6 就安全了吗?jackson-core 是另一个坐标,它那条 high 全局库至今没收
java·后端·安全·json
Ticnix1 小时前
我调了三个月 overlap=50,它其实一次都没生效
后端·python·agent
斯维赤1 小时前
LangChain4j 入门教学(Java 后端狂喜版
java·后端
数据库小学妹1 小时前
MySQL库存扣减实战:原子UPDATE、分桶方案与锁范围分析
数据库·后端·mysql
旺仔不是程序员1 小时前
LIMIT 1:PostgreSQL 只取一行的高效查询姿势
数据库·后端·sql
知守观1 小时前
Java POI 动态二级表头导出实战:并集计算 + 合并单元格 + 冻结列的完整实现
后端
知守观1 小时前
Snowflake 雪花算法实战:41位时间戳里的三个坑(时钟回拨、workerId、位运算)
后端
花间相见2 小时前
【计算基础|网络04】—— HTTP接口实战(下):接口测试、鉴权与跨域排错
java·linux·人工智能·后端·python·计算机网络·postman