老炮踩坑录 · 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 |
必须在运行时通过反射读取。SOURCE 和 CLASS 阶段拿不到 |
第二步:设计切面
切点表达式
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 清空用户信息
三处都清了,彻底干净。
完整数据流
设计原则总结
| 设计决策 | 背后原则 | 说明 |
|---|---|---|
| 空注解 | 标记注解模式 | 注解只做标记,不做配置。逻辑集中在切面 |
| 按包名拦截 | 简单优于灵活 | 一个切点覆盖所有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;
}
老炮点评
老炮语录:
权限控制方案的好坏,不看用了什么技术,看用户能不能无感知。 ------Spring Security 很强大,但如果用户被踢到登录页骂娘,那就是失败的设计。
"空注解是最好的注解。" ------注解里放属性,看似灵活,实则增加了理解成本。标记就是标记,干净利落。
"AOP 切面是'隐式调用',写的时候爽,调试的时候哭。" ------代码里看不到调用关系,断点打在Controller里对不上执行顺序。解法:切面里加详细日志。
"三处存储不是冗余,是容灾。" ------Session 会丢,Cookie 会过期,Cache 会溢出。三个都不可靠,但组合在一起,可靠性就上去了。
"没有最好的鉴权方案,只有最合适的。" ------小项目用注解+AOP,中项目用拦截器+JWT,大项目用Spring Security。选型的关键是"匹配",不是"先进"。
下期预告:《POI 动态二级表头算法:并集 + 合并 + 冻结列的完整实现》
这篇文章我们讲了上篇提到的3个亮点。下篇我们分析这个项目里让我折腾了三天的模块------POI 动态二级表头算法:并集 + 合并 + 冻结列优雅实现。一级表头不固定、二级表头要合并、还要冻结列,网上的方案没一个能用。下期我把完整算法拿出来做深入的分析。
我是老炮,18年奋斗在一线的Java老兵。这里记录真实项目里的踩坑经历。关注我,少走弯路。