@AuthCheck 权限注解落地路径(AI 大模型评测平台的自研鉴权方案)

几十个接口都要先回答”当前用户是谁、有没有权限做这件事”,鉴权逻辑的组织方式直接决定代码会不会失控。起初直接在 Controller 里手写 if-else 判断登录态、判断角色,代码很快就变得又长又碎,更糟的是容易漏校验——有一次联调时发现某个管理接口没做角色校验,普通用户登录后居然能直接调用。当时我把方案对比了一圈:Spring Security 功能全但配置重,默认拦截链对我们这种”登录+角色”两档的中小型平台来说负担过大;Shiro 的注解也够用,但要引入整个权限框架,学习成本不低。最后我选择自研 @AuthCheck 注解配合 AOP 切面,把”当前用户能做什么”集中成一段可复用的横切逻辑,一百多行代码就把问题收干净了。下面把落地思路、关键代码和踩过的坑完整记下来,给同样在做中小型平台鉴权的人一个参考。

一、设计目标

动手写注解之前,我先列了一张”每个接口都在重复写什么”的清单,发现重复逻辑集中在三件事上:① 校验登录态(token 解析后确认用户是否存在);② 校验角色权限(普通用户/管理员/超级管理员);③ 把当前登录用户注入方法参数,让业务代码直接 CurrentUser.get(),省掉每个接口重复 request.getAttribute("user") 的样板代码。设计目标就定为:用一个注解同时覆盖这三件事。

三件事单独拿出来都不难,难点在于组合在一起,而且对业务代码零侵入。我也评估过用拦截器(Interceptor)实现的方案:拦截器能拿到 request 和路径,但角色判断得写一堆路径白名单,新增一个接口忘了加规则就漏了;路径正则写多了之后,谁配了什么规则根本记不住。对比下来,注解方式的收益很直接——权限要求和接口声明放在一起,代码审查时一眼就能看出哪个接口谁能调、谁不能调。

方案上线前我做了一次小验证:拿线上最典型的十个接口,分别用手写校验和注解两种方式实现,对比代码量。手写版平均每个接口多二三十行重复代码,十个接口就多出两百多行;注解版统一收进切面,业务代码零增量。这个数据坚定了我往下做的信心——不止是省事,更是把”会不会漏校验”从人肉保证变成框架保证。

二、注解定义

先定义注解本身,这一步解决”用什么标记一个接口需要鉴权”的问题:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
    /** 必填角色,可多个 */
    String[] mustRole() default {};
}

这里只保留一个属性 mustRole,保持简单。注意它是数组而不是单个字符串,这样能天然表达”任一角色通过即可”的语义,比如 {"admin","superAdmin"} 表示管理员或超管都能调。如果未来需要”必须同时具备多个角色”的语义,再扩展一个 allRole 属性也不迟,当前平台只有 user/admin/superAdmin 三档角色,数组已经够用。

定义阶段还有个容易被忽略的细节:@Retention 必须配成 RUNTIME。早期我写过一次默认的 CLASS 保留策略,切面在运行期拿不到注解信息,拦截直接失效,联调时发现所有接口都放行,排查了半天才定位到这里。这个坑虽小却值得记一笔,AOP 配合自定义注解属于典型的”定义容易、生效难”,四件套(Target、Retention、注解声明、切面签名)少一环都不转。

三、AOP 拦截实现

注解只是声明,真正干活的是切面。这段代码解决”token 解析、角色校验、用户注入如何串成一条完整链路”的问题,骨架如下:

@Around("@annotation(authCheck)")
public Object check(ProceedingJoinPoint pjp, AuthCheck authCheck) throws Throwable {
    HttpServletRequest req = ((ServletRequestAttributes) 
        RequestContextHolder.getRequestAttributes()).getRequest();
    String token = req.getHeader("token");
    ThrowUtils.throwIf(StringUtils.isBlank(token), ErrorCode.NOT_LOGIN_ERROR);

    // 1) 解析 token 拿 userId
    long userId;
    try { userId = JwtUtil.parse(token); }
    catch (Exception e) { throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "token失效"); }

    // 2) 查用户角色
    User user = userService.getById(userId);
    ThrowUtils.throwIf(user == null, ErrorCode.NOT_LOGIN_ERROR);

    // 3) 校验角色
    String need = user.getUserRole();
    if (authCheck.mustRole().length > 0) {
        boolean ok = Arrays.stream(authCheck.mustRole())
                          .anyMatch(r -> r.equals(need));
        ThrowUtils.throwIf(!ok, ErrorCode.NO_AUTH_ERROR);
    }

    // 4) 通过 ThreadLocal 注入用户
    CurrentUser.set(user);
    Object res = pjp.proceed();
    CurrentUser.clear();
    return res;
}

这段代码的走向很清晰:token 解析失败、用户不存在、角色不匹配分别抛 NOTLOGINERROR、NOTLOGINERROR、NOAUTHERROR,错误码各不相同,前端可以据此给出不同的跳转提示。校验通过后把用户塞进 ThreadLocal,业务方法里直接 CurrentUser.get() 就能拿到登录人。需要提醒一点:CurrentUser.clear() 放在 proceed() 之后,如果业务方法抛异常就会跳过这行,线程被复用时下一个请求会拿到上一个用户的身份,所以生产环境必须用 try-finally 包住 proceed(),保证任何路径都清理。

四、关键设计取舍

方案落地过程中,每个环节都有候选方案,取舍标准只有一个:平台现状下哪种更简单可靠。下面从五个维度记录当时的选择:

决策 方案 为什么这样选
拦截方式 AOP 注解 业务代码零侵入,新增接口只挂注解
登录态来源 Header token 平台统一用 Session+Redis,前端用 Authorization 头携带
用户上下文 ThreadLocal 一个请求一个线程,Controller/Service 层直接 CurrentUser.get()
异常处理 抛 BusinessException 与全局异常处理器联动,鉴权失败返回统一 JSON 格式
角色粒度 枚举字符串 避免过度设计,平台只有 user/admin/superAdmin 三档

这几项里争议比较大的是”登录态来源”。当时有同事建议把用户对象直接放进 session,靠 Spring 的 HttpSession 拿登录人,简单是简单,但网关做负载均衡后要开 sticky 会话,后续扩展分布式部署会被卡住。现在统一走 token + Redis,网关无状态,加节点不用改任何配置。角色粒度选枚举字符串而不是数据库动态角色表,是因为平台角色固定三档,动态表反而把简单问题复杂化,还多一层缓存同步的麻烦。

五、典型使用

定义和切面都就绪后,业务侧的接入方式非常简单。我们把它总结成三步,新同事照着做就能独立接一个受保护的接口:

  1. 在方法上加 @AuthCheck,按接口语义填 mustRole 角色数组;
  2. 方法体里通过 CurrentUser.get() 拿当前登录用户,不再直接读 request;
  3. 自测时分别用未登录、无权限、有权限三个账号各调一次,确认三档错误码都正确。

下面这段是实际效果示例,解决”新接口鉴权成本高”的问题:

@PostMapping("/model/add")
@AuthCheck(mustRole = {"admin", "superAdmin"})
public BaseResponse<Long> addModel(@RequestBody ModelAddRequest req) {
    // 不用再写 if 校验,业务直接拿当前用户
    User u = CurrentUser.get();
    return ResultUtils.success(modelService.add(req, u.getId()));
}

一个接口一行注解,controller 里完全看不到鉴权代码。跑通后新接口的接入成本从原来平均半小时降到一分钟以内,代码 review 也轻松很多——先看有没有 @AuthCheck,再确认角色列表对不对,权限缺口基本一眼能发现。对我们团队来说,更大的变化是新人接手接口开发时不用再问”这个接口要不要鉴权”,看注解就知道,规则写在声明里,比任何文档都可靠。

六、踩过的坑

方案上线后,真正的考验才开始。下面这四处坑是平台运行过程中陆续踩到的,每一个都值得记一笔:

  • 切面优先级:因为限流注解 @RateLimit 也用了 AOP,要把 @AuthCheck 切面设成更高的优先级(数值更小),先鉴权再限流,否则已鉴权的请求被限流后白白浪费 token 解析。
  • ThreadLocal 清理:方法返回后必须 clear(),否则线程被复用时下一个请求会拿到上一个用户的身份,这是这类拦截器特别容易出 bug 的地方。
  • 内部方法调用失效:同类内 this.xxx() 不走 AOP,注解就失效了,要么改成注入自身 bean 调用,要么把鉴权逻辑下沉到 Service。
  • Redis Session 失效:token 解析成功但 Redis 中 session 已过期,应视为”未登录”而不是”无权限”,给前端可重新登录的机会。

这些坑补完之后,鉴权模块基本稳定下来,线上没再出过权限相关的安全事故。回头看,自研注解方案的维护成本比想象中低——核心代码就是切面那一百多行,剩下全是业务侧的零散使用,出了问题翻日志、看错误码就能定位。如果哪天平台角色模型复杂到需要动态权限表,再切换框架也不迟,现有注解层的语义和错误码都可以平滑迁移。

常见问题(FAQ)

Q1:为什么不直接用 Spring Security?

Spring Security 配置复杂、默认拦截链长,对这种只有”登录+角色”两档的中小型平台来说太重,自研注解+ AOP 一百行代码搞定。

Q2:mustRole 是数组的意义?

兼容”任一角色通过即可”的语义,例如 {"admin","superAdmin"} 表示管理员或超管都能调,比写两个注解更直观。

Q3:ThreadLocal 为什么要在 finally 里 clear?

保证异常路径也清理,避免线程复用导致身份串号。生产环境用 try-finally 包裹 proceed() 是必须的。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部