采云台 · M4 权限(角色鉴权)· 深度分析
对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M4 相关内容
依据:backend/cai-yun-tai实际源码逐行核验;mvn -o -DskipTests compile退出码 0
一句话定位:M4 的设计骨架(认证在拦截器、授权在切面,两者职责分离)是清晰且站得住的;但它的默认策略是”未标注即放行”,而且所有管理端读接口都没有角色限制——这导致一个具体的信息泄露:采购专员可以拉取全部员工的身份证号。
零、先给出准确的覆盖率数字(不要用旧文档的数字)
我把管理端每个 Controller 的写接口和 @RequireRole 逐条对了一遍:
| Controller | 写接口数(@Post/@Put/@Delete) | @RequireRole 数 | 写接口覆盖 |
|---|---|---|---|
| GoodsController | 4 | 4 | ✅ 100% |
| ComboController | 4 | 4 | ✅ 100% |
| CategoryController | 4 | 4 | ✅ 100% |
| DepartmentController | 3 | 3(+3 个读接口给 {ADMIN,PURCHASER}) | ✅ 100% |
| OrderController | 5 | 5 | ✅ 100% |
| EmployeeController | 3(不含 login/logout) | 3 | ✅ 100% |
| BudgetController | 2 | 2(+1 个读接口给 {ADMIN,PURCHASER}) | ✅ 100% |
| CommonController | 1 | 1 | ✅ 100% |
| ShopController | 1 | 1 | ✅ 100% |
| WorkSpaceController | 0 | 0 | — |
| 合计 | 27 | 31(含 4 个读接口的保护) | 写接口 27/27 = 100% |
结论修正:仓库里的《Redis 改造复核清单》写的是「27/28 写接口已覆盖(仅 logout 未加,合理)」。我实测是 27/27 全覆盖——logout 不属于需要角色限制的写接口(它只删自己请求头里那个 token)。这个数字要说准确,否则被要求现场核对时会尴尬。
同时给出另一边的事实:管理端 15 个读接口全部无注解(按设计放行),另外 login 通过拦截器 excludePathPatterns 排除。
一、业务场景:为什么需要”角色”而不是”两套账号”
1.1 三种角色的真实职责
| 角色 | 常量 | 端 | 能做什么 | 不能做什么 |
|---|---|---|---|---|
普通员工 EMPLOYEE | RoleConstant.EMPLOYEE | 员工端 /user/** | 浏览物资/组合包、加入申领车、提交申领单、查自己的单、催办、确认收货、发起退换 | 登录管理端(被 EmployeeServiceImpl.login 显式拒绝) |
采购专员 PURCHASER | RoleConstant.PURCHASER | 管理端 /admin/** | 审批通过/驳回、取消、发货、核销;查看部门列表、预算列表、申领单 | 维护基础数据(物资、分类、组合包、部门、预算额度、员工账号);上传文件;改采购开关 |
行政管理员 ADMIN | RoleConstant.ADMIN | 管理端 /admin/** | 全部权限 | — |
1.2 为什么用 role 字段而不是两张表 / 两套账号
【原始方案】员工表 employee 管管理端,用户表 user 管客户端(原苍穹外卖就是这样:employee 是管理端员工,user 是微信用户)。
【原始方案的问题】在采云台的场景下行不通:
- 采云台的”客户端用户”就是公司员工本人——他既要在员工端提申领单,也可能是采购专员要在管理端审批;
- 如果用两张表,同一个人要有两个账号、两套密码、两份部门归属,还要处理”这两条记录是同一个人”的映射问题;
- 权限判断会变成”先看在哪张表,再看角色”,逻辑分叉。
【当前方案】一张 employee 表 + role 字段,两端共用同一套账号密码。user 表保留但不使用(migration_office_supplies.sql 里用注释标记为「【已废弃】原微信用户表,身份已合并至 employee,仅保留历史数据」)。
证据:User 实体类仍然存在于 caiyuntai-pojo,但全项目零引用(无 Mapper、无 Service 使用),是留档性死代码。
1.3 端隔离与角色分离是两件事(这是理解 M4 的关键)
┌──────────────────────────────────────────┐
│ 同一张 employee 表 │
│ username / password / role / dept_id │
└──────────────────────────────────────────┘
│
┌─────────────────────────┴─────────────────────────┐
│ │
【端隔离】认证层 【角色分离】授权层
两套 JWT 密钥 + 两个请求头 @RequireRole + RoleAspect
│ │
┌────▼─────┐ ┌─────▼──────┐
│ 管理端 │ token 请求头 / adminSecretKey = itcast │ EMPLOYEE │ 被 login 拒绝
│ /admin/** │ 拦截器额外要求 role != EMPLOYEE │ PURCHASER │ 只能审批/发货/核销
└──────────┘ │ ADMIN │ 全部权限
┌──────────┐ └────────────┘
│ 员工端 │ authentication 请求头 / userSecretKey = itheima
│ /user/** │ 拦截器只要求账号未停用(任何角色都能进)
└──────────┘
注意一个容易讲错的点:拦截器里的 role != EMPLOYEE 检查属于端隔离(“你这种人根本不该进这个端”),不是功能级授权(“你能不能点这个按钮”)。真正的功能授权在切面里。面试时说清这个区分,比说”拦截器做了权限校验”要准确。
二、技术实现:三个组件
2.1 自定义注解
@Target({ElementType.METHOD, ElementType.TYPE}) // 可标方法、可标类
@Retention(RetentionPolicy.RUNTIME) // 运行时可反射读取
public @interface RequireRole {
String[] value(); // ★ 无默认值 = 使用时必须传
}两个设计点:
String[]而不是单个String—— 支持”任一满足即放行”。例如部门列表允许{ADMIN, PURCHASER}都能看;@Target包含TYPE—— 允许标在类上做”整类默认”,方法上可覆盖。但当前项目没有任何一个类使用类级标注(31 处全部是方法级)。
2.2 切面
@Aspect
@Component
@Slf4j
public class RoleAspect {
@Before("@annotation(com.caiyuntai.annotation.RequireRole) || @within(com.caiyuntai.annotation.RequireRole)")
public void checkRole(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// ① 方法上的注解优先,没有再看类上的
RequireRole requireRole = method.getAnnotation(RequireRole.class);
if (requireRole == null) {
requireRole = method.getDeclaringClass().getAnnotation(RequireRole.class);
}
// ② ★ 没有注解 → 直接放行(默认放行策略)
if (requireRole == null) {
return;
}
// ③ ★ 注解值为空数组 → 也直接放行(隐蔽陷阱)
String[] allowed = requireRole.value();
if (allowed.length == 0) {
return;
}
// ④ 取当前角色
String currentRole = BaseContext.getCurrentRole();
if (currentRole == null) {
// 没有角色信息 = 请求没经过登录拦截器 → 按未登录处理
log.warn("角色校验失败:未获取到登录角色,方法={}", method.getName());
throw new BaseException(MessageConstant.USER_NOT_LOGIN);
}
// ⑤ anyMatch:任一角色满足即放行
boolean pass = Arrays.stream(allowed).anyMatch(role -> role.equals(currentRole));
if (!pass) {
log.warn("角色校验失败:当前角色={},允许角色={},方法={}",
currentRole, Arrays.toString(allowed), method.getName());
throw new BaseException(MessageConstant.NO_PERMISSION);
}
}
}切点表达式的含义(要能解释清楚):
| 表达式 | 含义 |
|---|---|
@annotation(com.caiyuntai.annotation.RequireRole) | 匹配执行方法上标注了该注解的连接点 |
@within(com.caiyuntai.annotation.RequireRole) | 匹配目标类上标注了该注解的连接点 |
A || B | 两者任一命中即触发通知 |
为什么要合并两个表达式? 因为 @annotation 只管方法级、@within 只管类级。要让”方法级 + 类级”两种用法都生效,必须用 || 组合。这是一个容易被忽略的细节——只写 @annotation 的话,类级标注会静默失效。
2.3 角色上下文(BaseContext 的第二个 ThreadLocal)
public class BaseContext {
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>(); // 当前登录人 id
public static ThreadLocal<String> roleThreadLocal = new ThreadLocal<>(); // 当前登录人角色
public static void setCurrentRole(String role) { roleThreadLocal.set(role); }
public static String getCurrentRole() { return roleThreadLocal.get(); }
public static void clear() { // ← 拦截器 afterCompletion 调用
threadLocal.remove();
roleThreadLocal.remove();
}
}为什么角色要单独放一个 ThreadLocal 而不是查出来传参?
- 与
currentId同理:避免把登录上下文一路当方法参数往下传(Service、切面、Mapper 切面都要用); - 但要意识到代价:ThreadLocal 是隐式参数,让方法的依赖关系变得不可见——
RoleAspect读BaseContext,但它的方法签名上没有任何提示。这是”M4 用起来干净但排错困难”的根源,也是后文 M4-7(异步线程里失效)的伏笔。
三、完整代码执行链路
★★★ 链路 1:认证与授权的完整顺序(M4 的核心,必须能画出来)
以”采购专员尝试新增物资(越权)“为例——这是最能体现分层设计的一条链路,因为它会走到授权层才被拒绝:
PUT /admin/goods
Header: token = {采购专员 PURCHASER 的 JWT}
Body: {...}
┌─【第 1 层】Servlet 容器 (Tomcat)
│ 解析 HTTP 请求,交给 Spring MVC 的 DispatcherServlet
│
├─【第 2 层】HandlerMapping:找到目标 Handler
│ 匹配到 GoodsController.update(GoodsDTO),包装成 HandlerMethod
│
├─【第 3 层】★ HandlerInterceptor.preHandle() ← 认证(只管"是否登录")
│ 执行 JwtTokenAdminInterceptor.preHandle:
│ ├─ handler instanceof HandlerMethod ? → 是
│ ├─ token = request.getHeader("token") ← 从请求头取
│ ├─ JwtUtil.parseJWT(adminSecretKey="itcast", token)
│ │ ├─ 验签(HS256)失败 → 抛异常 → 401
│ │ └─ 过期 → 抛 ExpiredJwtException → 401
│ ├─ empId = claims.get("empId") ← 取工号
│ ├─ Redis GET login:token:{token} ← ★ 白名单校验
│ │ 返回 null(已登出 / 被踢)→ response.setStatus(401); return false
│ ├─ employee = employeeMapper.getById(empId) ← ★ 查库(每次请求都查!)
│ │ ├─ employee == null → 401
│ │ ├─ role == "EMPLOYEE" → 401 ← ★ 端隔离:普通员工禁入管理端
│ │ └─ status == DISABLE(0) → 401 ← ★ 账号停用即时失效
│ ├─ BaseContext.setCurrentId(empId) ← 写入 ThreadLocal
│ └─ BaseContext.setCurrentRole("PURCHASER") ← ★ 写入角色,供切面使用
│ return true → 放行
│
├─【第 4 层】HandlerAdapter:参数解析
│ ├─ @RequestBody GoodsDTO → JacksonObjectMapper 反序列化(自定义日期格式)
│ └─ ⚠️ 注意:JSON body 在这一层已经被反序列化了
│
├─【第 5 层】★★ Spring AOP:RoleAspect.checkRole() ← 授权(管"能不能做这件事")
│ ├─ 切点匹配:GoodsController.update 上有 @RequireRole(ADMIN) 吗?
│ │ @annotation 命中 → 进入通知
│ ├─ method.getAnnotation(RequireRole.class) → @RequireRole(ADMIN)
│ ├─ allowed = ["ADMIN"]
│ ├─ currentRole = BaseContext.getCurrentRole() → "PURCHASER"
│ ├─ anyMatch("ADMIN".equals("PURCHASER")) → false
│ ├─ log.warn("角色校验失败:当前角色=PURCHASER,允许角色=[ADMIN],方法=update")
│ └─ throw new BaseException(MessageConstant.NO_PERMISSION) ← "无操作权限"
│
├─【第 6 层】异常传播 + 全局异常处理
│ BaseException extends RuntimeException ← ★ 运行时异常
│ ↓ 未被捕获,冒泡到 DispatcherServlet
│ GlobalExceptionHandler(@RestControllerAdvice)
│ @ExceptionHandler(BaseException ex)
│ → log.error(...); return Result.error(ex.getMessage())
│ ↓
│ 响应:HTTP 200,body = {"code":0, "msg":"无操作权限", "data":null}
│ ⚠️ 注意是 HTTP 200 而不是 403 —— 业务错误码放在 body 里
│
└─【第 7 层】★ HandlerInterceptor.afterCompletion() ← 清理 ThreadLocal
BaseContext.clear() ← 无论成功、失败、异常都会执行
这一条链路里有 7 个可以直接展开的面试点:
| # | 面试点 | 要点 |
|---|---|---|
| 1 | 为什么认证和授权分两层 | 认证在拦截器(能拿到 HandlerMethod,粒度是”请求”);授权在切面(能反射读方法/类注解,粒度是”方法”,还能支持方法级覆盖类级)。职责分离 |
| 2 | 拦截器里为什么每次都查库 | 为了拿到 role(JWT 里只放了 empId)并实时校验 status。代价是每次请求一次主键查询,好处是禁用账号能立即生效。优化方向:把 role/status 也放进 JWT,或缓存到 Redis——但那样就失去了实时性 |
| 3 | 为什么 role == EMPLOYEE 要在拦截器里拦 | 这是端隔离,不是功能授权。EmployeeServiceImpl.login 已经拦了一道(普通员工不允许登录管理端),拦截器这道是防御”管理端 token 被降级角色的员工复用”——双保险 |
| 4 | 授权在参数解析之后 | JSON body 已经反序列化了。影响:越权请求的 body 仍会被解析(浪费一点 CPU),但控制器方法绝对不会执行。要在参数解析前拦,得放进拦截器的 preHandle —— 这是 M4-6 要讨论的取舍 |
| 5 | 越权返回 HTTP 200 还是 403 | 当前是 200 + code:0。这是项目的统一约定(所有业务错误都是 200 + code:0)。要说清这是约定,不是疏漏;但严格说”认证失败 401、授权失败 200”是不一致的 |
| 6 | BaseException 为什么能被全局处理器捕获 | 它 extends RuntimeException,所以不受”受检异常”的限制,能穿过所有方法签名冒泡到 @RestControllerAdvice。对比:userCancelById 等方法声明了 throws Exception,那些受检异常不会被 GlobalExceptionHandler 捕获(它只声明了两个 handler)→ 会走 Spring 默认的 500 |
| 7 | afterCompletion 为什么必须调用 clear() | Tomcat 复用线程。如果不清,下一个请求如果没走拦截器(比如静态资源、doc.html),getCurrentId() 会读到上一个请求的身份。这正是原苍穹外卖项目的已知缺陷——它写了 BaseContext.removeCurrentId() 但从未调用 |
链路 2:普通员工尝试登录管理端(端隔离,被拒绝在认证之前)
POST /admin/employee/login
Body: {"username":"zhangsan", "password":"123456"} ← zhangsan 是 EMPLOYEE
↓ 【拦截器不生效】
WebMvcConfiguration 里:admin 拦截器 excludePathPatterns("/admin/employee/login")
★ 这是唯一被豁免的管理端路径
↓ EmployeeController.login(EmployeeLoginDTO)
↓ EmployeeServiceImpl.login(dto) ← 第 49 行
├─ checkLogin(dto) ← 账号密码 + 状态校验
│ ├─ Redis GET login:fail:zhangsan ← 失败次数 ≥ 5 → 抛 AccountLockedException
│ ├─ employeeMapper.getByUsername("zhangsan")
│ ├─ password = DigestUtils.md5DigestAsHex(...) ← 无盐 MD5
│ ├─ 不匹配 → incrLoginFail → 抛 PasswordErrorException
│ ├─ status == DISABLE → 抛 AccountLockedException
│ └─ 成功 → Redis DEL login:fail:zhangsan
└─ if (RoleConstant.EMPLOYEE.equals(employee.getRole()))
throw new AccountLockedException(MessageConstant.NO_ADMIN_PERMISSION)
← ★ "当前账号无权登录管理端"
↓ GlobalExceptionHandler → Result.error("当前账号无权登录管理端")
对照:同一套账号打 POST /user/user/login → staffLogin() → 不做角色检查 → 登录成功
这里有一个设计上的细节值得讲:拒绝普通员工登录管理端时,抛的是 AccountLockedException(“账号被锁定”这个异常类),但消息是 NO_ADMIN_PERMISSION(“当前账号无权登录管理端”)。
- 异常类的语义和消息不匹配——用了”账号锁定”的异常类型来表达”无权限”;
- 影响:如果以后有人按异常类型做分支处理(比如”账号锁定就提示联系管理员”),会误判;
- 面试被问到时的诚实回答:「这里复用了
AccountLockedException,语义上应该是NoPermissionException之类。因为项目里没有为’无权限登录’定义专门的异常类,我当时复用了已有的。属于可以清理的技术债。」
回归证据:regress_api_test.sh 第 2 节「普通员工不允许登录管理端(角色隔离)」专门验证了这一条。
链路 3:@RequireRole 的角色组合全景(决定”谁能做什么”)
【仅 ADMIN】
POST /admin/goods 新增物资
DELETE /admin/goods 批量删除物资
PUT /admin/goods 修改物资
POST /admin/goods/status/{s} 物资启停
POST /admin/combo 新增组合包
DELETE /admin/combo 批量删除组合包
PUT /admin/combo 修改组合包
POST /admin/combo/status/{s} 组合包启停
POST /admin/category 新增分类
DELETE /admin/category 删除分类
PUT /admin/category 修改分类
POST /admin/category/status/{s} 分类启停
POST /admin/department 新增部门
PUT /admin/department 修改部门
DELETE /admin/department 删除部门
POST /admin/budget 设置预算 ← 资金相关
PUT /admin/budget 修改预算 ← 资金相关
POST /admin/employee 新增员工
POST /admin/employee/status/{s} 启用禁用员工
PUT /admin/employee 编辑员工
POST /admin/common/upload 文件上传
PUT /admin/shop/{status} 设置采购开关
─────────────────────────── 共 22 处,全部写操作 + 资金操作
【ADMIN + PURCHASER】
PUT /admin/order/confirm 审批通过
PUT /admin/order/rejection 审批驳回
PUT /admin/order/cancel 取消申领单
PUT /admin/order/delivery/{id} 发货
PUT /admin/order/complete/{id} 核销完成
─────────────────────────── 共 5 处,采购专员的业务职责
【ADMIN + PURCHASER】(读接口,为了配合审批时查看上下文)
GET /admin/department/list 部门列表(审批时要按部门筛选)
GET /admin/department/listEnabled 启用部门(下拉框)
GET /admin/department/{id} 部门详情
GET /admin/budget/list 预算列表(审批时参考余额)
─────────────────────────── 共 4 处
【无注解 = 默认放行(任何管理端角色都能访问)】★ 15 个读接口
GET /admin/goods/page 物资分页
GET /admin/goods/{id} 物资详情
GET /admin/goods/list 物资列表
GET /admin/combo/page 组合包分页
GET /admin/combo/{id} 组合包详情
GET /admin/category/page 分类分页
GET /admin/category/list 分类列表
GET /admin/order/conditionSearch 申领单搜索 ← 含全公司单据、申请人电话/地址
GET /admin/order/statistics 各状态数量
GET /admin/order/details/{id} 申领单详情 ← 含全公司单据
GET /admin/employee/page 员工分页 ← ★★ 含身份证号!
GET /admin/employee/{id} 员工详情 ← ★★ 含身份证号!
GET /admin/shop/status 采购开关
POST /admin/employee/logout 登出(无角色要求,合理)
(login 由拦截器豁免)
【无角色要求 = 任何已登录员工都能访问】员工端全部接口
/user/goods/list、/user/combo/list、/user/category/list、
/user/shoppingCart/**、/user/order/**、/user/addressBook/**、/user/budget/my
四、为什么这么设计
4.1 为什么用”自定义注解 + AOP”而不是 Spring Security
【业务场景】 需要一个轻量的、只区分三种角色的功能级鉴权。
【原始方案对比】 这里”原始方案”指的是备选方案,因为项目起步时就是空白的。
| 方案 | 实现 | 优点 | 缺点 | 本项目是否适用 |
|---|---|---|---|---|
| A. 自定义注解 + AOP 切面(当前) | @RequireRole + @Before | 轻:一个注解 + 一个 30 行切面;直观:权限要求写在方法签名上,一眼可见;与项目已有模式一致(@AutoFill + AutoFillAspect);零依赖 | 默认放行(漏标即静默失效);不支持动态权限(角色硬编码在注解里,改权限要改代码重新部署);没有细粒度(数据级)权限;没有会话管理、CSRF 防护、密码加密策略等一整套能力 | ✅ 选它 |
| B. Spring Security | 引入 spring-boot-starter-security,配置 SecurityFilterChain + @PreAuthorize | 功能完备:认证、授权、会话、CSRF、密码加密(BCrypt)、方法级安全、Remember-Me;社区标准,简历加分 | 学习曲线陡(FilterChain、AuthenticationManager、UserDetailsService 一整套);配置量大(本项目要做两个独立的认证链:管理端和员工端);与现有的 JWT+拦截器方案冲突,等于重写认证层;@PreAuthorize 的 SpEL 表达式调试困难 | ⚠️ 未使用。对这个规模是过度设计 |
| C. Apache Shiro | Subject + @RequiresRoles | 比 Spring Security 简单;会话管理友好 | 社区活跃度下降;本项目无会话(纯 JWT)用不上它的强项 | ⚠️ 未使用 |
| D. 拦截器里判断(不用 AOP) | 在 preHandle 里反射读 HandlerMethod 的方法注解 | 能在参数解析之前拦截;不受 Spring AOP 自调用失效、final 方法失效的影响;一处集中,天然覆盖所有路径 | 需要自己写反射逻辑(但和现在切面里的代码量差不多);要处理”注解在类上还是方法上”;会让拦截器承担两个职责(认证 + 授权),违反单一职责 | ⚠️ 其实是很合理的选择,见 M4-6 的讨论 |
| E. 数据库 RBAC 表驱动 | role / permission / role_permission / user_role 四张表 + 缓存 | 权限动态可配,改权限不用改代码;支持自定义角色 | 复杂度大幅上升:要建 4 张表、要做权限缓存、要写权限查询与判断逻辑、管理端要有角色权限配置界面 | ⚠️ 未实现。本项目的角色是固定的三种、由代码定义,没有”管理员自定义角色”的需求 |
【为什么选 A】
「选自定义注解是因为这个项目的权限模型是固定的三种角色,由代码定义,不存在”运维在界面上配角色”的需求。Spring Security 功能完整但配置量大,而且它的强项(会话管理、CSRF、
UserDetailsService)我基本用不上——我用的是无状态的 JWT。更重要的是与项目已有模式的一致性:我已经有了
@AutoFill+AutoFillAspect这个”自定义注解 + AOP”的先例,权限继续用同一个模式,代码风格统一、学习成本为零。但我也清楚它的代价:一是默认放行,漏标就静默失效(这是 M4-1 的问题);二是权限硬编码在注解里,想改”哪些角色能审批”必须改代码重新部署。如果以后要做动态权限,就得上 RBAC 表驱动那套。」
4.2 为什么”默认放行”而不是”默认拒绝”(这是 M4 最核心的设计取舍)
【当前策略】 RoleAspect 第 40-42 行:
RequireRole requireRole = method.getAnnotation(RequireRole.class);
if (requireRole == null) {
requireRole = method.getDeclaringClass().getAnnotation(RequireRole.class);
}
if (requireRole == null) {
return; // ★ 没标注 → 直接放行
}注解放开后,项目的 javadoc 也明确承认了这一点(RequireRole.java 第 13-15 行):
设计取舍:未标注该注解的方法默认放行(RoleAspect 只在命中注解时才校验)。
因此管理端所有「写接口」必须显式标注@RequireRole,避免采购专员等非管理员角色越权维护基础数据;读接口可省略注解(默认放行)。
【为什么选默认放行 —— 真实的理由】
- 读接口占多数:管理端 15 个读接口 vs 27 个写接口,而且读接口的权限需求是”所有管理端角色都能看”。默认放行让这 15 个接口不用写注解,减少了 15 处样板代码;
- 员工端完全不需要这个注解:员工端天然只有一种角色(能登录员工端的都是员工),全部放行是对的。如果默认拒绝,员工端的每一个接口都要标注——这是纯粹的负担;
- 演进路径:项目是从原苍穹外卖改造来的,那边完全没有权限注解(只有登录拦截器)。引入
@RequireRole时选择”默认放行 + 按需标注”,是增量、不破坏现有行为的改法。
【默认放行的问题 —— 必须承认】
| 问题 | 说明 |
|---|---|
| 新增接口忘记标注 → 静默越权 | 这是最危险的:没有编译错误、没有运行时警告、测试也不会失败。一个采购专员可以直接调这个新接口 |
| 依赖人记住,没有机制保障 | 覆盖率 100% 是靠人工核对得来的,不是靠构建/测试强制 |
| ”看起来有权限控制”的错觉 | 因为 27 个写接口都标了,review 的人可能会认为权限是完整的,从而忽略读接口完全没有限制这一点 |
【替代方案对比】
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| A. 默认放行(当前) | 没注解 = 放行 | 读接口和员工端零负担;增量改造成本低 | 漏标即静默越权;无机制保障 |
| B. 默认拒绝 | 没注解 = 拒绝(或要求显式 @PermitAll) | 漏标会变成”访问被拒”而不是”静默放行”——错误立刻暴露,反而安全 | 15 个读接口 + 员工端全部接口都要显式标注;员工端和 /admin/employee/login 这类豁免路径需要额外的白名单机制 |
C. 只对 /admin/** 的写接口默认拒绝 | 按 HTTP 方法区分:@PostMapping/@PutMapping/@DeleteMapping 无注解即拒绝 | 精准命中风险面(写操作才有越权危害);读接口保持零负担 | 实现要在切面里读 @RequestMapping 的方法类型,稍复杂;logout 这类”写但不危险”的接口要额外处理 |
| D. 编译期/测试期强制 | 写一个测试遍历所有 /admin/** 写接口,断言都带 @RequireRole;或者用 ArchUnit 做架构测试 | 不改运行期行为,只在 CI 里失败 | 项目没有 CI、没有这类测试;而且”带注解”不等于”注解正确” |
【面试标准答法 —— 这段要能一口气说完】
「当前是默认放行——
RoleAspect里if (requireRole == null) return;。选它的理由有两条:一是管理端 15 个读接口的权限需求都是”所有管理端角色都能看”,员工端天然只有一种角色,如果默认拒绝,这 20 多个接口全都要写注解,是纯粹的样板负担;二是项目是从一个没有权限注解的代码库改造来的,默认放行使改造是增量的、不破坏已有行为。
但我必须承认它的风险:新增管理端写接口时忘记标注,就是静默越权——没有编译错误、没有警告、测试也不会失败。我的 27 个写接口 100% 覆盖是靠人工逐条核对得到的,不是靠任何机制保证的。
如果要改进,我会选”按 HTTP 方法区分”的折中:只对
/admin/**的写接口(@PostMapping/@PutMapping/@DeleteMapping)默认拒绝,读接口保持默认放行。因为只有写操作才有越权的实际危害,读接口的风险是信息泄露(这是另一个问题),这样既不增加大量样板、又把最危险的面收紧了。并且让”漏标”变成”访问被拒”而不是”静默放行”——让错误暴露出来,比事后审计有效。」
4.3 为什么认证在拦截器、授权在切面(职责分层)
【切分点】 两层各自回答一个问题:
| 层 | 组件 | 回答的问题 | 手段 |
|---|---|---|---|
| 认证 | JwtTokenAdminInterceptor / JwtTokenUserInterceptor | 「你是谁?你登录了吗?」 | JWT 验签 + Redis 白名单 + 查库校验角色/状态 |
| 授权 | RoleAspect | 「你登录了,但你能做这件事吗?」 | 反射读 @RequireRole + 比对 BaseContext 里的角色 |
【为什么授权不放拦截器里 —— 项目自己的理由】(RoleAspect 第 24-27 行的 javadoc 原文):
之所以用切面而不是在拦截器里判断,是因为拦截器只能拿到 HandlerMethod 的类型信息,而注解的读取与方法级覆盖(类上标注、方法上覆盖)用反射处理更自然,也和项目里 AutoFillAspect 的”自定义注解 + 切面”模式保持一致。
【这个理由站得住吗?说实话:部分站得住,部分不站得住。
| 项目给的理由 | 客观评价 |
|---|---|
「拦截器只能拿到 HandlerMethod 的类型信息」 | ⚠️ 不准确。拦截器里 (HandlerMethod) handler 能直接拿到 getMethod() 和 getBeanType(),读类/方法注解完全没有障碍。这个理由技术上说不通 |
| 「注解的读取与方法级覆盖用反射更自然」 | ✅ 站得住。而且切面里的 method.getAnnotation() → 没有再查 getDeclaringClass() 这个”方法优先、类兜底”的逻辑,写在切面里确实更自然 |
「和 AutoFillAspect 的模式保持一致」 | ✅ 站得住,而且是最重要的一条——项目里两个横切关注点(自动填充、权限)用同一个模式,风格统一、可维护性更好 |
【授权放切面的真实得失】
| 维度 | 放切面(当前) | 放拦截器 preHandle |
|---|---|---|
| 执行时机 | 参数解析之后(@RequestBody 已反序列化) | 参数解析之前(body 还没解析) |
| 性能 | 越权请求仍会反序列化 body(浪费 CPU) | 越权请求直接短路,零浪费 |
| 失效风险 | ⚠️ Spring AOP 的经典陷阱:同类内部自调用不走代理;final/private 方法不会被代理(不过 Controller 方法都是 public,且不存在自调用) | ✅ 不受 AOP 代理机制影响,只要是 Spring MVC 接管的请求就一定经过 |
| 职责 | 单一(拦截器只管认证) | ⚠️ 拦截器要管两件事,变厚 |
| 风格一致性 | ✅ 与 @AutoFill 一致 | ❌ 与项目既有模式不一致 |
【面试标准答法 —— 注意这里要敢纠正项目注释的不准确之处】
「我把认证放在拦截器、授权放在切面。理由是职责分离:拦截器回答”你是谁、登录了吗”,切面回答”你登录了但能不能做这件事”。这两件事的失败语义完全不同——认证失败应该是 401,授权失败是”无权限”。
关于”为什么授权不放拦截器”:项目代码注释给的理由是”拦截器只能拿到
HandlerMethod的类型信息,读注解不方便”。这个理由其实不太站得住——拦截器里((HandlerMethod) handler).getMethod()就能直接拿到方法对象、读注解毫无障碍。我认为真正站得住的理由有两个:一是风格一致性——项目里已经有
@AutoFill+AutoFillAspect这个”注解 + 切面”的先例,权限用同一个模式,代码风格统一、学习成本为零;二是切面里”方法注解优先、类注解兜底”这段逻辑写起来更自然。但切面方案有一个真实的代价我要说清:它的执行时机在参数解析之后,所以越权请求的 JSON body 已经被反序列化了——控制器方法不会执行,但解析的开销已经花了。如果放在拦截器的
preHandle里,越权请求会被在参数解析之前短路掉。所以我这个方案的授权是”安全但不够早”。另外还有 AOP 本身的失效风险:同类内部自调用不走代理、
final方法不被代理。我的 Controller 方法都是public且没有自调用,所以当前不受影响;但如果以后有人在 Controller 里调自己的另一个带注解的方法,权限校验会静默失效。拦截器方案没有这个问题。」
4.4 为什么 @RequireRole 的值是数组
【业务场景】 有些接口需要多个角色都能访问。
例子:GET /admin/department/list(部门列表)——采购专员在审批申领单时需要按部门筛选,所以他也得能看:
@RequireRole({RoleConstant.ADMIN, RoleConstant.PURCHASER})【原始方案】 String value()(单个角色)。
【原始方案的问题】 遇到多角色可访问时只能:
- 要么拆成多个注解(
@RequireRole(ADMIN) @RequireRole(PURCHASER)—— 但 Java 不允许同一注解重复标注,除非加@Repeatable); - 要么干脆不标注解(默认放行)——这就是最坏的解法:不仅失去了限制,还让”为什么这个接口没注解”变成无法回答的问题。
【当前方案】 String[] value() + anyMatch:
boolean pass = Arrays.stream(allowed).anyMatch(role -> role.equals(currentRole));【语义选择:anyMatch(OR)而不是 allMatch(AND)】
- OR 是唯一合理的选择:
@RequireRole({ADMIN, PURCHASER})的语义显然是”管理员或采购专员都能访问”。 - AND 没有业务意义:一个人不可能同时是两个角色(
employee.role是单个字符串字段,不是集合)。如果哪天真需要 AND,那说明权限模型要改成”多角色”了。
⚠️ 但数组带来一个隐蔽陷阱 —— 空数组等于放行:
String[] allowed = requireRole.value();
if (allowed.length == 0) {
return; // ★ @RequireRole({}) 等于没标注,放行
}因为 value() 没有默认值(String[] value(); 不带 default),所以必须显式传参——@RequireRole({}) 是完全合法的 Java 写法,但它的效果是”看着加了注解,实际没有任何限制”。
这是一个真实的、隐蔽的坑:写的人以为加了权限控制,review 的人看到有注解也认为安全,实际上完全放行。
【修法建议】
public @interface RequireRole {
String[] value();
// 或者在切面里改为:空数组 = 拒绝(fail-safe)
}// RoleAspect 里改成
if (allowed.length == 0) {
log.warn("角色校验失败:@RequireRole 未指定任何角色,方法={},拒绝访问", method.getName());
throw new BaseException(MessageConstant.NO_PERMISSION); // 空数组 = 拒绝
}面试标准答法:
「注解的值是数组,因为有些接口需要多个角色访问,比如部门列表我允许
{ADMIN, PURCHASER}都能看——采购专员审批申领单时要按部门筛选。判断用anyMatch也就是 OR 语义,因为一个人只有一个角色字段,AND 没有业务意义。但这里有个隐蔽的坑我要主动说:切面里对空数组是直接放行的——
@RequireRole({})的效果等于没加注解。因为value()没有默认值,写@RequireRole({})是合法语法,但它看着像加了权限、实际完全没限制。这个坑比”漏标注解”更危险,因为漏标至少是可见的疏忽,而空数组是伪装的保护。修法是在切面里把空数组改成拒绝,或者在注解里用@Repeatable让多角色有更清晰的表达方式。」
4.5 ThreadLocal 的生命周期与泄漏
【设计】 拦截器 preHandle 写入、afterCompletion 清理(JwtTokenAdminInterceptor 第 98-100 行、JwtTokenUserInterceptor 第 95-97 行):
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
BaseContext.clear(); // threadLocal.remove() + roleThreadLocal.remove()
}【为什么必须清理 —— 真实的事故场景】
Tomcat 的线程是池化复用的(默认 200 个线程)。如果不清理:
请求 A(管理员 admin,empId=1,role=ADMIN)→ 线程 T-7 处理
preHandle: threadLocal.set(1), roleThreadLocal.set("ADMIN")
★ 如果 afterCompletion 不执行 clear():
请求 B(某个静态资源请求,不走拦截器)→ Tomcat 又分配给线程 T-7
→ 拦截器没执行,BaseContext 没被覆盖
→ 如果 B 的链路里有代码调 BaseContext.getCurrentId()
→ ★ 读到的是 A 的身份(empId=1)!
后果取决于哪里读了它:
| 读取点 | 后果 |
|---|---|
assertOwner(orders.getUserId() 比对) | ✅ 会拒绝(因为 id 不匹配),是”误拒”不是”误放”——失败方向是安全的 |
@AutoFill 切面(填 createUser/updateUser) | ❌ 审计信息写错人——操作日志记录成上一个请求的人 |
RoleAspect(读 currentRole) | ❌❌ 最严重:如果刚才那个是 ADMIN,下一个请求的角色被当成 ADMIN → 越权 |
【好消息:项目实际上不受影响】 因为两个拦截器都正确实现了 afterCompletion 并调用 clear()。而且所有需要 BaseContext 的路径都在 /admin/** 或 /user/** 下,都会被拦截器覆盖或清理。
【注意这是一处”已修复的原项目缺陷”】 拦截器第 93-97 行的注释原文:
请求结束后清理 ThreadLocal。
Tomcat 复用线程,若不清理,下一个请求可能读到上一个请求的身份信息。
这是原项目遗漏的一环(原代码只写了 remove 方法却没有调用)。
这是一个非常好的面试素材:它说明你不是照着模板抄的,而是发现了原实现的问题并修了,而且能解释为什么。
【面试标准答法】
「我用
BaseContext存当前登录人 id 和角色,两个ThreadLocal。关键是必须在请求结束时清理——两个拦截器的afterCompletion里都调了BaseContext.clear()。为什么必须清:Tomcat 线程是池化复用的。如果不清理,一个没走拦截器的请求(比如静态资源)被分配到同一个线程,
BaseContext里就残留着上一个请求的身份。后果分三种:assertOwner会误拒(这个方向是安全的);@AutoFill会把createUser写成错的人;最严重的是RoleAspect会读到上一个请求的ADMIN角色,造成越权。这个清理在原项目里是缺失的——它定义了两个
remove方法,但从来没有调用。我在改造时补上了。这也提醒我 ThreadLocal 的一个本质问题:它让方法的依赖变得隐式——RoleAspect读BaseContext,但它的签名上看不出任何依赖,所以排查时很难找到数据从哪来。这也是为什么异步场景下它会失效:换了个线程,ThreadLocal 就是空的。」
4.6 拦截器为什么每次都查库(性能与实时性的取舍)
【当前实现】 两个拦截器的 preHandle 里都有:
Employee employee = employeeMapper.getById(empId); // ← 每次请求一次主键查询
if (employee == null || ...) { response.setStatus(401); return false; }
BaseContext.setCurrentRole(employee.getRole());【原始方案】 把 role 也放进 JWT 的 claims 里,拦截器只验签、不查库。
【原始方案的问题】
JWT 是自包含且不可撤销的:签发之后内容固定、到期之前一直有效。如果把 role 放进去:
员工 A 当前是 PURCHASER → 签发 JWT{empId:5, role:"PURCHASER"}
↓(管理员把 A 改成 EMPLOYEE,或者 A 调岗)
签发的旧 JWT 在 2 小时内【仍然是 role:"PURCHASER"】
→ ★ 降权不生效,A 在 2 小时内仍能审批申领单
同理,status(启用/停用)放进去也不行——禁用账号后旧 token 还能用 2 小时。
【当前方案】 每次请求查库拿最新的 role 和 status:
if (employee == null
|| RoleConstant.EMPLOYEE.equals(employee.getRole()) // 角色实时判断
|| StatusConstant.DISABLE.equals(employee.getStatus())) { // 状态实时判断
response.setStatus(401);
return false;
}【得失分析】
| 维度 | 每次查库(当前) | role 放 JWT |
|---|---|---|
| 降权/禁用实时性 | ✅ 立即生效(改库后下一个请求就 401) | ❌ 最长等到 JWT 过期(本项目 admin-ttl / user-ttl 都是 7200000 ms = 2 小时) |
| 每次请求开销 | ❌ 一次主键查询(走主键索引,约 0.1-0.5 ms) | ✅ 零查询 |
| 权限语义正确性 | ✅ 权限判断基于”当前数据库状态”(单一事实来源) | ❌ 基于”签发时刻的状态”,是快照,可能与现实不一致 |
| 可优化性 | 可以加 Redis 缓存或本地缓存(但引入缓存一致性问题) | 只能靠缩短 TTL |
【为什么选每次查库 —— 面试要这么讲】
「我选每次请求查库,而不是把
role放进 JWT。核心理由是权限判断必须基于当前状态,而不是签发时的快照。如果
role放在 JWT 里,因为 JWT 不可撤销,会出现”降权不生效”——把一个采购专员降成普通员工之后,他手里那张旧 token 在 2 小时内仍然是 PURCHASER,还能继续审批申领单。同理,禁用账号在 2 小时内也拦不住。而我的场景里”能不能审批、能不能维护基础数据”是权限问题,实时性比省一次查询重要得多。代价是每次请求多一次主键查询。 但这是主键索引等值查询,成本大概是零点几毫秒,而且在同一个连接池里、能被 Buffer Pool 缓存命中。我觉得这个开销换”权限实时生效”是值得的。
如果要优化,我会把
empId → {role, status}缓存在 Redis 里,改角色/改状态时主动删缓存。但那样就引入了”缓存与数据库不一致”的问题——而权限判断恰恰是不能不一致的地方,所以我倾向不缓存,宁可多查一次。」
⚠️ 但这里必须主动指出一个不一致:拦截器查库拿实时角色,但角色变更后,用户已经持有的 JWT 仍然有效(只是下次请求时角色会被重新读取)。这个组合是对的:
- JWT 里只有
empId(不携带任何权限信息); - 权限信息每次从数据库读。
所以本项目的 JWT 是”纯身份凭证”,不承载授权信息——这是一个干净的设计,值得讲。对比”把 role 放 JWT”的做法,本项目的权限判断没有快照问题。
五、M4 复习优先级文件清单(第五部分 · M4 切片)
M4 的代码量极小(注解 26 行 + 切面 67 行 + 两个拦截器共 199 行),但面试价值高。核心是能讲清分层和默认策略。
A 类:必须能背、能默写
| 优先级 | 文件 | 看什么 | 为什么 |
|---|---|---|---|
| P0 | RoleAspect.java 第 34-65 行(checkRole 方法,32 行) | 切点表达式(@annotation || @within)→ 方法注解优先于类注解 → null 放行 → 空数组放行 → BaseContext 取角色 → anyMatch | M4 的全部实现就在这 32 行。要能逐句解释,特别是两个放行分支 |
| P0 | RequireRole.java(26 行,含 javadoc) | @Target({METHOD, TYPE})、@Retention(RUNTIME)、String[] value() 无默认值、javadoc 里承认的”默认放行”设计取舍 | 26 行要全看。javadoc 第 13-16 行就是面试答案 |
| P0 | JwtTokenAdminInterceptor.java 第 49-101 行 | 认证 5 步 + role == EMPLOYEE 拒绝 + status == DISABLE 拒绝 + afterCompletion 清 ThreadLocal(含”原项目遗漏”的注释) | 认证层全部逻辑。afterCompletion 那段注释是面试金句 |
| P0 | BaseContext.java(55 行) | 两个 ThreadLocal(id + role)+ clear() | 要能说清”为什么必须清、不清会怎样、后果最严重的是什么” |
| P0 | 覆盖率数字 | 管理端 27 个写接口 / 27 个 @RequireRole(100%)、15 个读接口全部无注解、类级标注 0 处 | 必须说准,被要求现场核对时不能出错 |
B 类:知道配置与调用关系即可
| 文件 / 位置 | 看什么 |
|---|---|
| WebMvcConfiguration.java 第 40-50 行 | 拦截器注册:/admin/** 排除 login;/user/** 排除 login + shop/status |
| EmployeeServiceImpl.java 第 49-58 行 | login 里 if (RoleConstant.EMPLOYEE.equals(...)) throw AccountLockedException(NO_ADMIN_PERMISSION) |
controller/admin/*.java | 31 处 @RequireRole 的位置(扫一眼即可,不用背) |
| GlobalExceptionHandler.java | 只有两个 handler:BaseException 和 SQLIntegrityConstraintViolationException。RoleAspect 抛的 BaseException 走第一个 |
sql/regress_api_test.sh | 第 2 节(普通员工不能登录管理端)、第 5 节(未登录被拦 401)、第 14 节(员工 token 调管理端 401)、第 22 节(采购专员不能新增部门、不能改预算、可以查部门列表) |
C 类:不用看
RoleConstant(3 个字符串常量)Employee实体、EmployeeLoginDTO/EmployeeLoginVOAccountLockedException等异常类(知道都extends RuntimeException即可)
30 分钟复习顺序
1. RoleAspect 第 33-65 行 ← 10 分钟,能逐句讲,重点是两个放行分支
2. RequireRole javadoc 第 12-16 行 ← 2 分钟,这就是"默认放行"的官方说法
3. JwtTokenAdminInterceptor 第 49-101 行 ← 8 分钟,认证 5 步 + afterCompletion
4. 覆盖率数字(27/27 写、15 读无注解) ← 2 分钟,背下来
5. 自己讲一遍"为什么用 AOP 不用拦截器/Spring Security" + "默认放行的风险" ← 8 分钟
六、M4 面试追问链(8 层)
追问链 1:默认放行(M4 最核心的一条)
【面试官问题 1】
你这个权限注解是怎么生效的?
【推荐回答】
用自定义注解 + AOP 切面。定义了一个 @RequireRole 注解,值是一个角色数组;RoleAspect 用一个 @Before 通知,切点表达式是 @annotation(...) || @within(...)——也就是方法上有这个注解、或者类上有这个注解都会触发。通知里反射读出注解、拿到允许的角色数组,再和 BaseContext 里的当前角色做 anyMatch 匹配。
【回答关键词】 自定义注解 + @Before 切面 · @annotation || @within(方法级 + 类级)· 反射读注解 · anyMatch
【可能继续追问】 如果有个接口忘了加注解呢?
【面试官问题 2】
如果一个管理端接口忘了加这个注解,会怎样?
【推荐回答】
会直接放行,这是当前设计的一个已知风险。 切面里第一段逻辑就是:
RequireRole requireRole = method.getAnnotation(RequireRole.class);
if (requireRole == null) {
requireRole = method.getDeclaringClass().getAnnotation(RequireRole.class);
}
if (requireRole == null) {
return; // ★ 没注解 → 放行
}所以某天有人新增一个管理端写接口、忘记标注,任何能登录管理端的人——包括采购专员——都能直接调它。 而且这个错误是完全静默的:没有编译错误、没有运行时警告、也不会有测试失败。
【回答关键词】 主动承认 · 默认放行 · 忘标即静默越权 · 无任何提示
【可能继续追问】 那你为什么选默认放行?改成默认拒绝不行吗?
【面试官问题 3】
那为什么不改成默认拒绝?
【推荐回答】
可以改,而且我认同这个方向,但要说清当时的取舍。
选默认放行的理由有两条:
一是样板代码的量。我管理端有 15 个读接口,它们共同的权限需求是”所有管理端角色都能看”,如果默认拒绝,这 15 个都得写注解。更重要的是员工端——员工端天然只有一种角色(能登录员工端的都是员工),它那十几个接口全部需要权限吗?答案是”全部不需要”。如果默认拒绝,员工端每一个接口都要加一个”允许所有角色”的注解,这是纯粹的负担。
二是改造的增量性。这个项目是从一个完全没有权限注解的代码库改造来的——原来只有一个登录拦截器。引入 @RequireRole 时如果默认拒绝,会把所有现存接口一次性锁死,必须同时给每个接口都加注解才能不改行为,风险和后端工作量都大得多。默认放行让改造是增量的、不破坏现有行为。
但我承认默认放行的风险是实质性的。如果要改,我不会简单地全改成默认拒绝,而是选一个折中:只对 /admin/** 的写接口(@PostMapping/@PutMapping/@DeleteMapping)默认拒绝,读接口和员工端保持默认放行。
理由是:只有写操作才有越权的实际危害——写操作会改数据、会造成资金变动(比如改预算);而读接口的问题是信息泄露,那是另一类风险。这样既能收紧最危险的面,又不会增加大量样板代码。
【回答关键词】 样板代码量(15 读 + 员工端)· 改造增量性 · 折中方案:按 HTTP 方法区分 · 写操作才有实际危害 · 让漏标变成”被拒”而不是”放行”
【可能继续追问】 那你现在怎么保证没有漏标的?
【面试官问题 4】
那你怎么保证现在没有漏标的接口?
【回答回答】
说实话,靠人工核对,没有任何自动化保障。
我逐条把管理端的写接口和 @RequireRole 对了一遍:27 个写接口、27 个注解,覆盖率 100%。其中 22 个是 @RequireRole(ADMIN)(基础数据、预算、员工、上传、采购开关这些写操作),5 个是 @RequireRole({ADMIN, PURCHASER})(审批、驳回、取消、发货、核销)。
但我必须承认两件事:
第一,这个数字是人工数出来的,没有 CI、没有架构测试、没有构建期检查。如果有人加了一个新接口忘了标,我的这套核对方法完全发现不了。
第二,更值得说的是一件事——我只保证了”写接口有注解”,但整个管理端有 15 个读接口是完全没注解的。这不是漏标,是按默认放行的设计有意不标的,但它的后果我之前没有充分意识到:采购专员能读到他本不该看到的数据(具体见下一个问题)。
从工程上讲,正确的做法是让机制来保障,而不是靠人:
- 让”漏标”变成”访问被拒”——改成默认拒绝,漏标的后果从”静默放行”变成”接口用不了”,问题会立刻暴露,而不是等到安全审计;
- 或者写一个遍历式测试:用最低权限角色(
PURCHASER)的 token 去打所有/admin/**的写接口,断言全部返回拒绝。这样新增接口如果忘了标,测试会失败。
【回答关键词】 人工核对 · 承认无自动化保障 · 27/27 写接口 · 主动提出”15 个读接口完全无限制” · 让机制而非人保障
【可能继续追问】 你刚才说采购专员能读到不该读的数据?
【面试官问题 5】
采购专员能读到什么不该读的?
【推荐回答】
最具体的是员工身份证号。
管理端有两个接口没有 @RequireRole,所以任何管理端角色都能访问:
GET /admin/employee/page 员工分页
GET /admin/employee/{id} 员工详情
它们的返回类型是 Employee 实体,而 Employee 有这些字段:
private String username; // 登录账号
private String password; // ← 已被置为 "****",这一处做对了
private String phone;
private String sex;
private String idNumber; // ★★ 身份证号
private String role;
private Long deptId;EmployeeServiceImpl.pageQuery 和 getById 里都做了密码脱敏(setPassword("****"))——这一点是正确的,说明作者意识到要脱敏。但只脱了密码,idNumber 和 phone 是原样返回的。
所以采购专员只要调一个 GET 接口,就能拉出全公司员工的身份证号。
另外还有一处:申领单的两个读接口(conditionSearch、details/{id})也没有角色限制,返回的 OrderVO 里有全公司单据的申请人姓名、电话、收货地址。对采购专员来说查看申领单是他的职责、可以接受;但员工列表里的身份证号跟审批工作完全无关,纯粹是越权访问个人信息。
【回答关键词】 具体到接口和字段 · idNumber 身份证号 · 密码脱敏做了(说明有意识)但只脱了密码 · 申领单读接口可接受、员工身份证号无关职责 · 读接口的权限缺失
【可能继续追问】 那这个怎么修?
【面试官问题 6】
那这个怎么修?
【回答回答】
分三层,从治标到治本。
第一层,给这两个接口加注解。 员工信息属于”账号管理”,只有 ADMIN 应该能看:
@GetMapping("/page")
@RequireRole(RoleConstant.ADMIN)
public Result<PageResult> page(EmployeePageQueryDTO dto) { ... }这是几行改动、立刻见效的修法。
第二层,返回 VO 而不是实体。 现在直接返回 Employee 实体,等于把数据库表结构暴露给了接口——表里加个字段,接口就会自动多返回一个字段,这是很危险的默认行为。应该定义 EmployeeVO,只包含前端真正需要的字段(id、姓名、账号、手机号、角色、部门、状态),身份证号根本不进 VO。
密码脱敏这件事恰好说明问题:作者在 Service 里手工 setPassword("****"),说明他意识到了风险,但这是黑名单式的思路——只处理已知的敏感字段,新增敏感字段就会漏。而 VO 是白名单式:不显式加进 VO 的字段就不会返回,默认安全。
第三层,如果身份证号确实需要展示(比如管理员要核对身份),那也应该做掩码(110101********0011),并且这一行为应该记审计日志。
我的项目只做了第一层的一半——密码脱敏做了,但没有 VO、没有给这两个接口加角色限制。
【回答关键词】 加 @RequireRole(ADMIN) · 返回 VO 而不是实体(白名单 vs 黑名单)· 密码脱敏是黑名单思路、会漏 · 掩码 + 审计日志 · 承认只做了一半
【可能继续追问】 好,那说说认证和授权你是怎么分工的?
追问链 2:认证与授权的分层(6 层)
【面试官问题 1】
认证和授权你是怎么分工的?
【推荐回答】
两层,回答两个不同的问题。
认证在拦截器(JwtTokenAdminInterceptor / JwtTokenUserInterceptor):回答”你是谁、你登录了吗”。做四件事——验 JWT 签名和过期、校验 Redis 白名单(登出即失效)、查库确认账号没被停用且角色有资格进这个端、把 empId 和 role 写进 BaseContext。
授权在切面(RoleAspect):回答”你登录了,但你能不能做这件事”。反射读 @RequireRole 注解,和 BaseContext 里的角色比对。
这样分的好处是失败语义清晰:认证失败是 401;授权失败是业务错误码”无操作权限”。
【回答关键词】 认证答”是谁/登录了吗” · 授权答”能不能做” · 拦截器 vs 切面 · 失败语义不同(401 vs 无权限)
【可能继续追问】 那授权为什么不也写在拦截器里?
【面试官问题 2】
授权为什么不也写在拦截器里?不是更方便吗?
【推荐回答】
其实技术上完全可以,我要先纠正一下项目注释里的一个说法。
RoleAspect 的 javadoc 写的理由是”拦截器只能拿到 HandlerMethod 的类型信息,而注解的读取与方法级覆盖用反射处理更自然”。这个理由不太站得住——拦截器里 ((HandlerMethod) handler).getMethod() 和 getBeanType() 就能直接拿到方法对象和类对象,读注解毫无障碍。
我认为真正站得住的理由有两个:
一是风格一致性,这也是最重要的一条。项目里已经有一个 @AutoFill + AutoFillAspect 的”自定义注解 + AOP”先例用来做公共字段自动填充,权限继续用同一个模式,整个项目的横切关注点只有一种写法,学习成本和维护成本都更低。
二是切面里那段”方法注解优先、类注解兜底”的逻辑写起来更自然,而且和 @AutoFill 的处理方式如出一辙。
【回答关键词】 主动纠正项目注释 · 拦截器其实能读注解 · 真正理由是风格一致性(与 @AutoFill 一致)· 类/方法注解覆盖逻辑更自然
【可能继续追问】 那放切面有什么代价?
【面试官问题 3】
放切面有什么代价?
【推荐回答】
两个真实的代价,我都要说。
第一,执行时机更晚。 切面的 @Before 是在参数解析之后执行的,所以越权请求的 JSON body 已经被 Jackson 反序列化过了。控制器方法绝对不会执行(安全性没问题),但解析开销已经花了。如果放在拦截器的 preHandle 里,越权请求会在参数解析之前就被短路掉。
所以我的授权是”安全但不够早”——如果需要挡的是”超大 body 消耗内存”这类攻击,切面方案就挡不住。
第二,Spring AOP 本身的失效风险。 两个经典陷阱:① 同类内部自调用不走代理——如果 Controller 里 A 方法调了自己的 B 方法,B 上的 @RequireRole 不会生效;② final / private 方法不会被代理。
我的 Controller 方法都是 public、也没有自调用,所以当前不受影响。但这意味着这个安全性依赖”以后没人这么写”——如果哪天有人在 Controller 里抽了一个私有方法并加上注解,权限会静默失效。而拦截器方案完全没有这个隐患,只要是 Spring MVC 接管的请求就一定经过。
【回答关键词】 执行时机在参数解析之后(安全但不够早)· AOP 自调用失效 · final/private 不代理 · 当前不受影响但依赖约定 · 拦截器无此隐患
【可能继续追问】 那拦截器里每次都查库,不慢吗?
【面试官问题 4】
拦截器里每次请求都查一次数据库?为什么?不慢吗?
【推荐回答】
是有意的,为了权限的实时性。 我不把 role 放进 JWT,而是在每次请求时 employeeMapper.getById(empId) 拿最新的 role 和 status。
理由是 JWT 不可撤销、内容固定。 如果把 role 放进 JWT,会出现”降权不生效”:
员工 A 当前是 PURCHASER → 签发 JWT{empId:5, role:"PURCHASER"}
↓ 管理员把 A 改成 EMPLOYEE
A 手里那张旧 JWT 在 2 小时内【仍然自称 PURCHASER】
→ 降权不生效,他还能继续审批申领单
status(启用/停用)同理——禁用账号后旧 token 还能用 2 小时。而我的 TTL 配的是 7200000 毫秒,也就是 2 小时,这个窗口不算短。
所以本项目的 JWT 是”纯身份凭证”——它只携带 empId,不携带任何权限信息,权限每次从数据库读。 我觉得这是一个干净的设计:权限判断基于当前数据库状态(单一事实来源),而不是签发时刻的快照。
至于性能:这是一次主键索引等值查询,走 Buffer Pool 命中,成本大约零点几毫秒,而且在连接池里。我觉得用这个换”降权/禁用立即生效”是值得的。
如果要优化,我会把这个员工信息缓存在 Redis,改角色/改状态时主动删缓存。但我不倾向这么做——权限判断恰恰是最不能出现缓存不一致的地方,宁可多查一次库。
【回答关键词】 JWT 不可撤销 → role 放进去会降权不生效 · status 同理(TTL 2 小时窗口不短)· JWT 只携带 empId(纯身份凭证)· 主键查询成本可忽略 · 不缓存权限数据是有意的
【可能继续追问】 那你这个 ThreadLocal 怎么管理的?
【面试官问题 5】
BaseContext 用的是 ThreadLocal,有什么风险?
【推荐回答】
最大的风险是线程复用导致的”身份串号”,所以必须清理。
Tomcat 的线程是池化的。如果不清理,一个不走拦截器的请求(比如静态资源)被分配到同一个线程时,BaseContext 里就残留着上一个请求的身份。后果分三种:
assertOwner会误拒(id 不匹配)——这个方向是安全的;@AutoFill切面会把createUser/updateUser写成错的人——审计信息污染;- 最严重的是
RoleAspect会读到上一个请求的ADMIN角色,造成越权。
我的实现在两个拦截器的 afterCompletion 里都调了 BaseContext.clear(),而且 clear() 同时清两个 ThreadLocal(id 和 role),所以是干净的。
这里我要特别说一句:这个清理在原项目里是缺失的。 原来的代码定义了 removeCurrentId() 方法,但从来没有调用过。拦截器的注释里我留了记录。这说明一个本质问题:ThreadLocal 让方法依赖变得隐式——RoleAspect 读 BaseContext,但从它的方法签名上看不出任何依赖,排查时很难找到数据从哪来。
而且这也解释了为什么 ThreadLocal 在异步场景会失效:换了线程就是空的。所以我这个项目没有任何异步任务,某种程度上也回避了这个问题——如果以后加了 @Async,BaseContext 里的身份在异步线程里一定是 null,必须显式把 empId 当参数传进去。
【回答关键词】 线程复用串号 · 三种后果(误拒/审计污染/越权)· afterCompletion 清理 · 原项目遗漏了调用 · ThreadLocal 依赖隐式 · 异步场景一定失效
【可能继续追问】 那 employee.role 字段上为什么有索引?
追问链 3:权限模型与扩展(5 层)
【面试官问题 1】
你的角色是怎么定义的?为什么用字符串而不是枚举或数字?
【推荐回答】
三个角色,定义在 RoleConstant 里,是 String 常量:EMPLOYEE、PURCHASER、ADMIN。
用字符串的好处是”自解释”——直接查数据库 select role from employee 能看懂,日志里 角色校验失败:当前角色=PURCHASER 也能看懂。如果用数字 1/2/3,每次排查都要回来查”2 是什么角色”。
代价是没有类型安全:role 字段在实体里是 String,数据库是 VARCHAR(20),拼错一个字母(比如 "PURCHASER " 带空格)不会有任何编译错误,只会表现为”这个人的权限莫名其妙不对”。用枚举能避免这个问题,但要在 MyBatis 里配 TypeHandler 做映射,而且注解的值用枚举会更啰嗦。
数据库侧我用了一个 idx_employee_role 索引,因为管理端员工分页支持按 role 筛选。
【回答关键词】 String 自解释(DB、日志可读)· 代价是无类型安全(拼错静默失效)· 枚举需要 TypeHandler · idx_employee_role 索引支持按角色筛选
【可能继续追问】 如果以后要加一个”部门主管”角色呢?
【面试官问题 2】
如果以后要加一个”部门主管”角色,你的设计能支持吗?
【回答回答】
能加,但要改代码重新部署,这暴露了当前设计的局限。
加一个角色需要动四处:
RoleConstant加常量DEPT_MANAGER;- 数据库
employee.role是VARCHAR(20),不需要改表结构(这点设计得还行); - 给需要放行的接口补注解或改注解的数组(比如审批接口可能要变成
{ADMIN, PURCHASER, DEPT_MANAGER}); - 前端菜单要按新角色显示。
核心局限是:权限是硬编码在注解里的,是”代码即配置”。 改任何一条权限规则都要改代码 + 重新编译 + 重新部署。而且没有”数据级权限”——比如”部门主管只能审批本部门的申领单”,用 @RequireRole 根本表达不出来。
如果要做数据级权限,得在 Service 里加过滤条件,比如审批时校验 ordersDB.getRequesterDept() 是不是等于当前用户所在部门。这个我项目里没做——现在的审批接口是 {ADMIN, PURCHASER},任何采购专员都能审批任何部门的申领单,这也是一个真实的越权风险面。
【回答关键词】 能加但要改代码部署 · 权限硬编码 = 代码即配置 · 无法表达数据级权限 · 缺”只能审批本部门”的过滤(真实缺口)
【可能继续追问】 那要做数据级权限怎么办?
【面试官问题 3】
那数据级权限怎么做?
【推荐回答】
两种思路,成本差很多。
轻量做法:在 Service 里加过滤条件。 比如审批时校验单据的申请部门和当前用户的部门是否一致:
if (!employee.getDeptId().equals(ordersDB.getRequesterDept())) {
throw new OrderBusinessException(MessageConstant.NO_PERMISSION);
}或者更松的”只能看到本部门单据”——在 conditionSearch 的查询条件里强制注入 requesterDept(类似员工端 pageQuery4User 强制 setUserId(BaseContext.getCurrentId()) 的做法,这个模式项目里已经有了)。
优点:改动小、直接。缺点:权限规则散落在各个 Service 方法里,和”哪里能审批”这个业务逻辑混在一起,容易被后续改动漏掉。
重量做法:完整 RBAC + 数据权限表。 建 role / permission / role_permission / user_role,再加数据范围表(全部 / 本部门 / 本部门及下级 / 仅本人)。权限判断从注解改成查表(加缓存)。
优点:动态可配、不改代码就能调整权限、能表达数据级范围。缺点:从 1 个注解变成 4-5 张表 + 权限缓存 + 管理界面,复杂度上升一个数量级。
对当前项目我倾向轻量做法——因为现在只有三种固定角色、单级部门(parent_id 统一为 0),业务上还没有”数据范围”的诉求。但这个缺口是真实存在的:现在的采购专员能审批全公司所有部门的申领单。
【回答关键词】 轻量:Service 层加过滤 / 强制注入部门条件(复用 pageQuery4User 的模式)· 重量:完整 RBAC + 数据范围表 · 规则散落的缺点 · 当前缺口是”能审批全公司”
【可能继续追问】 那为什么不用 Spring Security?
【面试官问题 4】
为什么不用 Spring Security?用了会更规范吧?
【推荐回答】
更规范,但功能和成本都不匹配。
Spring Security 的强项我基本用不上:会话管理(我用无状态 JWT)、CSRF 防护(前后端分离 + 自定义请求头,本来就不靠 Cookie)、UserDetailsService(我只有一张 employee 表)、记住我、表单登录——这些都是它成熟的部分,但我一个都不需要。
而它的成本是实的:SecurityFilterChain、AuthenticationManager、UserDetailsService、PasswordEncoder 这一整套概念要理解;而且我这个项目有两个独立的认证链(管理端和员工端,不同密钥、不同请求头、不同的角色准入规则),在 Spring Security 里要么配两个 SecurityFilterChain 加 @Order,要么用 RequestMatcher 区分——配置复杂度明显上升,而且很容易配错(配错的表现是”安全放行”或者”全部 403”,都不好排查)。
如果真要用,我会考虑它真正的收益点:它的 BCryptPasswordEncoder ——因为我的项目现在用无盐 MD5 存密码,这是我明确知道的一个缺陷。但这一点我可以单独引入 Spring Security 的 crypto 模块(或者用 BCrypt 库)来解决,不需要引入整个框架。
所以我的判断是: 在这个规模下,自定义注解 + AOP 恰好匹配需求;引入 Spring Security 是用一整套框架解决一个 30 行代码的问题。但如果项目要上生产、要处理密码策略、账号锁定、密码找回、审计日志这一整套账号体系,我会改用 Spring Security。
【回答关键词】 它的强项(会话/CSRF/表单登录)我一个不需要 · 两个认证链的配置复杂度 · 真正想要的只是 BCrypt(可单独引入)· 规模匹配 · 什么条件下会换
【可能继续追问】 那你密码为什么用 MD5?
【面试官问题 5】
那你的密码为什么用 MD5?
【推荐回答】
这是项目一个明确的缺陷,我用的是无盐 MD5,应该说清风险和修法。
代码是 DigestUtils.md5DigestAsHex(password.getBytes())——无盐、无迭代。问题有两个:
一是 MD5 本身已经被破解得很彻底,碰撞和快速计算都有成熟工具,GPU 每秒能算几十亿次;
二是无盐意味着相同密码得到相同哈希,可以用彩虹表直接反查。我的演示数据里三个账号密码都是 123456,哈希都是 e10adc3949ba59abbe56e057f20f883e——只要库泄露,弱密码几乎瞬间被还原。
修法是换 BCrypt:自带随机盐(同一个密码每次哈希结果都不同)、可配置计算成本(strength 参数让每次哈希耗时几十到几百毫秒,抗暴力破解的关键就是”慢”)。
但换算法有一个实际障碍:存量数据兼容。 库里已经存了 MD5 的值,直接换会导致所有人登录不上。可行的迁移方案是双算法并存:登录时判断密码字段的格式——BCrypt 的哈希有固定前缀($2a$/$2b$),以它开头的走 BCrypt 校验,否则走 MD5 校验;如果是 MD5 校验通过,就顺便把密码升级成 BCrypt 存回去。这样用户无感知、逐步迁移完成。
我没有做这个迁移,因为改动涉及登录逻辑和存量数据,而且这个项目的密码安全还叠加了另一个问题:没有登录失败限流——这一条我后来修了(5 次锁定 15 分钟),但密码算法这块还是 MD5。
【回答关键词】 无盐 MD5 的两个问题 · 演示数据哈希相同可反查 · 换 BCrypt(随机盐 + 慢)· 存量迁移:按前缀双算法并存 + 登录时升级 · 承认没做
【可能继续追问】 (通常收尾或转向别的模块)
七、M4 容易被质疑的地方(严格视角,不强行合理化)
| # | 问题 | 面试官为什么可能质疑 | 当前代码实际情况 | 我应该怎么解释 | 是否建议修改 |
|---|---|---|---|---|---|
| M4-1 | 默认放行:漏标注解即静默越权 | ”新增一个管理端写接口忘记加注解会怎样?” | ✅ 成立。RoleAspect 里 if (requireRole == null) return;。管理端 27 个写接口 100% 覆盖靠人工核对,无 CI/测试保障 | 主动承认。选默认放行有理由(15 个读接口 + 员工端全部接口零负担;从无注解代码库增量改造),但风险是实质性的。推荐改成按 HTTP 方法区分:只对 /admin/** 的写接口默认拒绝,读接口保持放行。让”漏标”变成”接口被拒”而不是”静默放行” | 建议修(M4 最高优先级)。这是”看起来有权限控制、实际有盲区”的典型 |
| M4-2 | ★ 采购专员能拉取全部员工的身份证号 | ”采购专员能看到员工身份证号吗?” | ✅ 成立,具体可验证。GET /admin/employee/page 和 /{id} 无 @RequireRole;返回类型是 Employee 实体,含 idNumber(身份证号)、phone、username、role。EmployeeServiceImpl 里只脱敏了 password(setPassword("****")),idNumber 原样返回 | 主动承认,并给出三层修法:① 加 @RequireRole(ADMIN)(几行);② 返回 VO 而不是实体——现在是黑名单式脱敏(只处理已知敏感字段,新增字段就漏),VO 是白名单式(不进 VO 就不返回,默认安全);③ 若确需展示则做掩码 + 审计日志。注意要肯定”密码脱敏做了”这一点,说明有意识但方法不够 | 建议修(优先级高,是真实的数据泄露) |
| M4-3 | @RequireRole({}) 空数组等于放行 | ”注解的值如果传空数组呢?” | ✅ 成立。RequireRole.value() 无默认值(必须传),但 @RequireRole({}) 是合法语法;RoleAspect 里 if (allowed.length == 0) return; → 放行 | 「这是一个伪装的保护——写的人以为加了权限,review 的人看到有注解也认为安全,实际完全没限制。它比”漏标注解”更危险,因为漏标是可见的疏忽,空数组是看起来正确的错误。修法是把空数组改成拒绝(throw NO_PERMISSION),fail-safe。」 | 建议修(一行改动,消除一个隐蔽陷阱) |
| M4-4 | 管理端 15 个读接口全部无角色限制 | ”读接口不需要权限吗?“ | ⚠️ 按设计如此(默认放行),但后果未被充分评估:采购专员能读全部物资/分类/组合包/申领单/员工/预算列表。其中申领单读接口对采购专员是合理的(审批需要),员工读接口不合理(关联 M4-2) | 「读接口统一放行是为了让采购专员能完成审批工作——他要看申领单、看部门、看预算余额。但这个’统一’太粗了:员工列表/详情和采购专员的工作无关,属于信息泄露。正确的做法是逐个读接口确认权限需求,而不是按”读接口都不标”一刀切。」 | 建议逐个评估(至少修员工读接口) |
| M4-5 | 采购专员能审批全公司任何部门的申领单 | ”采购专员能审批别的部门的单吗?” | ✅ 成立。PUT /admin/order/confirm 是 @RequireRole({ADMIN, PURCHASER}),confirm() 里只校验单据状态,不校验申请部门。而 Department.parent_id 统一为 0(单级部门) | 「@RequireRole 只能表达角色级权限,表达不了数据级权限(“只能审批本部门的”)。要做得在 Service 里加过滤:校验 employee.getDeptId() 和 ordersDB.getRequesterDept() 是否一致,或者查列表时强制注入部门条件(这个模式项目里已经有了——员工端 pageQuery4User 就是强制 setUserId(BaseContext.getCurrentId()))。当前没做,所以是”任何采购专员能审批任何部门」 | 建议修(若业务上要求部门隔离) |
| M4-6 | 授权在参数解析之后执行 | ”越权请求的 body 还会被解析吗?“ | ⚠️ 成立。切面的 @Before 在 HandlerAdapter 完成参数解析(@RequestBody 反序列化)之后触发 | 「控制器方法绝对不会执行,安全性没问题;但 JSON body 已经被反序列化了,越权请求仍消耗了 CPU 和内存。这是”安全但不够早”。放在拦截器 preHandle 能更早短路。代价是要把”方法注解优先、类注解兜底”的反射逻辑搬进拦截器,以及破坏与 @AutoFill 的风格一致性。」 | 可选(要能讲清”不够早”这个代价) |
| M4-7 | BaseContext 在异步场景会失效 | ”如果以后加异步任务,BaseContext 还能用吗?“ | ⚠️ 潜在。项目当前无任何异步(无 @Async、无自定义线程池),所以不触发。但 BaseContext 被 assertOwner、@AutoFill 切面、submitOrder 依赖 | 「ThreadLocal 绑线程,异步任务换了线程就是空的。所以一旦引入 @Async 或线程池,BaseContext.getCurrentId() 会返回 null——assertOwner 里 getCurrentId().equals(...) 会直接 NPE。正确做法是显式把 empId 当参数传进异步任务,不能依赖 ThreadLocal。项目现在没有异步,所以没踩到;但这是一个”加异步就会炸”的隐患。」 | 不需要改(但要心里有数,且加异步时必须处理) |
| M4-8 | login 拒绝普通员工时复用了 AccountLockedException | ”为什么抛的是’账号被锁定’?“ | ⚠️ 成立。EmployeeServiceImpl.login:53-55 抛 AccountLockedException,但消息是 NO_ADMIN_PERMISSION(“当前账号无权登录管理端”)。异常类的语义和消息不匹配 | 「这里复用了 AccountLockedException 来表达”无权限登录管理端”,语义上应该是一个 NoPermissionException 之类。当时因为项目里没有为”无权限登录”定义专门的异常类,我复用了已有的。如果有按异常类型做分支处理的代码,会误判。属于可以清理的技术债。」 | 建议清理(定义专门异常,或改用 BaseException) |
| M4-9 | 越权返回 HTTP 200 + code:0,而认证失败返回 401 | ”为什么认证失败是 401、授权失败却是 200?“ | ⚠️ 成立,是不一致的。认证失败 → response.setStatus(401);授权失败 → 抛 BaseException → GlobalExceptionHandler → HTTP 200 + {"code":0,"msg":"无操作权限"} | 「这是项目的统一约定——所有业务错误都是 HTTP 200 + code:0,前端统一看 code 和 msg。但严格说”认证 401 / 授权 200”是不一致的,更规范的做法是授权也用 403。当时的考虑是让前端只维护一套错误处理逻辑。如果对接第三方或做监控告警,401/403/500 的区分是有意义的。」 | 可选(约定问题,非缺陷;但要知道不一致) |
| M4-10 | employee.role 是字符串,无类型安全 | ”角色拼错了会怎样?“ | ⚠️ 成立。Employee.role 是 String,DB 是 VARCHAR(20)。拼错(如 "PURCHASER " 带空格)无编译错误,只表现为权限异常 | 「用字符串的好处是自解释——查库、看日志都能直接读懂,不用回头查”2 是什么角色”。代价是没有类型安全,拼错静默失效。用枚举能避免,但要在 MyBatis 配 TypeHandler,而且注解值用枚举更啰嗦。这是一个可读性 vs 类型安全的取舍,我倾向可读性;如果要严谨,可以在 save/update 时校验 role 在合法集合内。」 | 可选(建议加角色合法性校验) |
| M4-11 | User 实体是死代码 | ”这个 User 类是干什么的?” | ✅ 成立。caiyuntai-pojo 里有 User 实体(原微信用户),全项目零引用(无 Mapper、无 Service)。migration_office_supplies.sql 把 user 表注释标记为「【已废弃】」 | 「原项目的微信用户模型,身份已经合并到 employee 表了。实体和表都没删,只是标记废弃——表保留是为了留历史数据(迁移脚本里明确写了”不物理删除”),但实体类已经没有引用,应该删掉,否则会让人以为还有一套用户体系。」 | 建议删除实体(表可保留留档) |
| M4-12 | Swagger/knife4j 文档端点不受任何拦截器保护 | ”接口文档接口需要登录吗?“ | ⚠️ 成立。两个拦截器只拦 /admin/** 和 /user/**;/doc.html、/v2/api-docs、/swagger-resources/**、/webjars/** 都不匹配,所以无需登录即可访问 | 「接口文档是未授权可访问的。风险不是直接的越权(拿到文档也要有 token 才能调接口),而是信息暴露——攻击者能拿到完整的接口清单、参数结构、字段名,等于拿到了一份攻击面清单。项目甚至已经把接口文档导出成了 JSON 文件。如果部署在公网,文档端点必须加保护或关闭(比如生产环境用 @Profile("!prod") 关掉 Swagger,或者给文档路径也加拦截器)。」 | 建议修(生产环境必须关闭或加认证) |
明确不是问题、但可能被问的点
| 点 | 回答 |
|---|---|
为什么 logout 没有 @RequireRole | 合理的。logout 只删除自己请求头里那个 token(stringRedisTemplate.delete(LOGIN_TOKEN_PREFIX + token)),没有越权危害。而且它在 /admin/** 下,已经被认证拦截器保护(必须是有效登录态才能调) |
员工端所有接口都没有 @RequireRole | 正确的设计。能登录员工端的只有员工(任何角色都能登录员工端),而员工端的功能对所有员工都是开放的。员工端的数据隔离靠的不是角色,而是”强制使用当前登录人 id”——比如 pageQuery4User 强制 setUserId(BaseContext.getCurrentId())、assertOwner 校验单据归属 |
| 两个拦截器都写了一遍、代码几乎重复 | 可以抽象,但重复的只有”验签 + 查白名单 + 查库”这三步,而两端的校验规则不同:管理端额外要求 role != EMPLOYEE,员工端不要求;两端的密钥和请求头名不同。抽象成一个模板方法基类可行,但收益不大、反而增加了”改一个影响两个”的风险 |
@RequireRole 用了 anyMatch 而不是 allMatch | 正确的选择。{ADMIN, PURCHASER} 的语义是”或”,而且 employee.role 是单个字符串字段(不是角色集合),一个人不可能同时是两个角色,AND 没有业务意义。如果要支持 AND,得先把权限模型改成”多角色关联表” |
切点表达式为什么要 || 组合两个 | 因为 @annotation 只管方法级、@within 只管类级。要同时支持”标在方法上”和”标在类上”,必须用 ||。只写 @annotation 的话,类级标注会静默失效 |
八、M4 关键数字与事实速查(面试前扫一眼)
| 事实 | 值 / 位置 |
|---|---|
| 角色数量 | 3 个:EMPLOYEE / PURCHASER / ADMIN(RoleConstant) |
| 角色存储 | employee.role,VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' + idx_employee_role 索引 |
| 关键设计 | 一张 employee 表三角色(原 user 表已废弃、保留数据不删) |
| 注解定义 | @Target({METHOD, TYPE}) + @Retention(RUNTIME) + String[] value()(无默认值) |
| 切面 | RoleAspect,@Before("@annotation(...) || @within(...)"),67 行,checkRole 方法 32 行 |
| 判权逻辑 | 方法注解优先 → 类注解兜底 → 都没有则放行 → anyMatch(OR 语义) |
| 覆盖率(实测) | 管理端写接口 27/27 = 100%;@RequireRole 标记共 31 处(22 个纯 ADMIN + 9 个含 PURCHASER);类级标注 0 处;读接口 15 个全部无注解 |
| 认证层 | 两个拦截器 + JWT(双密钥:admin=itcast / user=itheima;双请求头:token/authentication`)+ Redis 白名单 + 每次查库拿实时 role/status |
| 拦截路径 | /admin/**(排除 /admin/employee/login);/user/**(排除 /user/user/login、/user/shop/status) |
| ThreadLocal | BaseContext 两个(threadLocal = id、roleThreadLocal = role),afterCompletion 调 clear()(原项目漏了这次调用) |
| 越权校验模式 | 员工端靠 ThreadLocal 而不是角色:pageQuery4User 强制 setUserId;assertOwner 校验单据归属 |
| 异常 | RoleAspect 抛 BaseException(extends RuntimeException)→ GlobalExceptionHandler → HTTP 200 + code:0;认证失败则是 401 |
| 已知缺口 | ① 默认放行(漏标即越权,无机制保障)② 采购专员可读全部员工身份证号 ③ @RequireRole({}) 空数组 = 放行 ④ 15 个读接口无限制 ⑤ 采购专员可审批全公司单据(无数据级权限)⑥ 授权在参数解析后 ⑦ BaseContext 异步即失效 ⑧ 密码无盐 MD5 ⑨ Swagger 文档未授权可访问 ⑩ User 实体死代码 |
| 回归验证 | regress_api_test.sh 第 2 节(员工不能登管理端)、第 5 节(未登录 401)、第 14 节(员工 token 调管理端 401)、第 22 节(采购专员不能新增部门 / 不能改预算 / 可以查部门列表) |
| 未实现 | Spring Security、Shiro、RBAC 表驱动、数据级权限、多角色关联、审计日志、角色合法性校验 |
九、如果只给我 3 分钟讲 M4
设计:三种角色(
EMPLOYEE/PURCHASER/ADMIN)共用一张 employee 表,靠role字段区分。原项目的user表(微信用户)已废弃,身份合并到 employee。分层:认证在拦截器、授权在切面。拦截器回答”你是谁、登录了吗”——验 JWT、查 Redis 白名单、每次查库拿最新的 role 和 status(为了降权/禁用立即生效,所以 JWT 只携带 empId、不携带任何权限信息,是纯身份凭证);切面回答”你登录了能不能做这件事”——自定义
@RequireRole注解 +@Before切面,切点用@annotation || @within同时支持方法级和类级。覆盖率:管理端 27 个写接口 100% 覆盖(22 个纯
ADMIN、5 个{ADMIN,PURCHASER})。我知道的问题,必须主动说:
- 默认放行——
RoleAspect里没注解就直接return。所以新增写接口忘标注就是静默越权,没有编译错误也没有警告。覆盖率 100% 是人工核对出来的,没有任何机制保障。如果要改,我会只对/admin/**的写接口默认拒绝——因为只有写操作才有实际危害,而且能让漏标从”放行”变成”被拒”、错误立刻暴露;- 最具体的一个越权是:采购专员能拉全部员工的身份证号。
GET /admin/employee/page和/{id}没有注解限制,返回类型是Employee实体,里面有idNumber。密码脱敏做了(setPassword("****")),但只脱了密码——这是黑名单式思路,新增敏感字段就会漏。正确做法是返回 VO(白名单式,不进 VO 就不返回);@RequireRole({})空数组等于放行——因为value()无默认值但空数组是合法语法。这是个伪装的保护,比漏标更危险,应该改成 fail-safe 拒绝;@RequireRole只有角色级权限,没有数据级权限——所以任何采购专员能审批全公司任何部门的申领单。要做得在 Service 里加部门过滤;- ThreadLocal 的清理是原项目缺失的一环,我补上了——不清的话线程复用会导致
RoleAspect读到上一个请求的ADMIN角色,造成越权。而且这也说明 ThreadLocal 依赖是隐式的,一旦加异步任务就一定失效;- 另外两个相关短板:密码是无盐 MD5(应换 BCrypt + 双算法迁移),Swagger 文档端点未授权可访问(生产必须关闭)。