采用 AOP切面 + 自定义注解 的方式实现统一的用户登录验证,消除重复代码,提升代码质量和可维护性。
| 对比项 | 手动验证(旧) | AOP切面(新) |
|---|---|---|
| 代码复用性 | ❌ 每个方法重复写验证代码 | ✅ 只需一个注解 |
| 代码行数 | ~10行/接口 | 1行/接口 |
| 维护成本 | ❌ 修改验证逻辑需改所有接口 | ✅ 只需修改切面类 |
| 易用性 | ❌ 容易遗漏或写错 | ✅ 一个注解搞定 |
| 可扩展性 | ❌ 难以扩展 | ✅ 支持多种验证策略 |
| 优雅程度 | ❌ 业务代码与验证逻辑混杂 | ✅ 业务代码简洁清晰 |
旧代码(手动验证):
@GetMapping("/getNoticeStatistics")
public R<JSONObject> getNoticeStatistics(@RequestParam("receiverId") String receiverId) {
log.info("NoticeController.getNoticeStatistics?receiverId={}", receiverId);
// ❌ 每个接口都要写这些重复代码
JSONObject userInfo = validateUserLogin();
if (userInfo == null) {
return R.fail("请先登录");
}
try {
JSONObject result = noticeService.getNoticeStatistics(receiverId);
return R.data(result, "查询成功");
} catch (Exception e) {
log.error("...", e);
return R.fail(e.getMessage());
}
}
新代码(AOP切面):
@LoginRequired // ✅ 只需一个注解!
@GetMapping("/getNoticeStatistics")
public R<JSONObject> getNoticeStatistics(@RequestParam("receiverId") String receiverId) {
log.info("NoticeController.getNoticeStatistics?receiverId={}", receiverId);
try {
JSONObject result = noticeService.getNoticeStatistics(receiverId);
return R.data(result, "查询成功");
} catch (Exception e) {
log.error("...", e);
return R.fail(e.getMessage());
}
}
┌─────────────────────────────────────────────┐
│ Controller层 (接口层) │
│ - 添加 @LoginRequired 注解 │
│ - 专注于业务逻辑 │
└─────────────────┬───────────────────────────┘
│
↓
┌─────────────────────────────────────────────┐
│ Aspect层 (切面层) │
│ - LoginAspect 拦截所有标注注解的方法 │
│ - 统一执行登录验证逻辑 │
│ - 验证失败直接返回错误,验证成功放行 │
└─────────────────┬───────────────────────────┘
│
↓
┌─────────────────────────────────────────────┐
│ Annotation层 (注解层) │
│ - @LoginRequired 自定义注解 │
│ - 支持灵活配置(是否验证类型、Redis等) │
└─────────────────────────────────────────────┘
客户端请求
↓
Controller接口(标注@LoginRequired)
↓
LoginAspect拦截(@Around环绕通知)
↓
提取JWT Token → 解析用户信息
↓
验证用户类型(storePlatform)
↓
验证Redis中Token是否存在
↓
验证通过?
├─ 否 → 返回 R.fail("请先登录")
└─ 是 → 执行业务方法 → 返回结果
文件路径:alien-store-platform/src/main/java/shop/alien/storeplatform/annotation/LoginRequired.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LoginRequired {
/**
* 是否验证用户类型
* 默认true,验证用户类型必须为storePlatform
*/
boolean checkUserType() default true;
/**
* 是否验证Redis中的Token
* 默认true,验证Token在Redis中存在
*/
boolean checkRedisToken() default true;
}
注解参数说明:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
checkUserType |
boolean | true | 是否验证用户类型为 storePlatform |
checkRedisToken |
boolean | true | 是否验证Redis中的Token |
使用示例:
// 默认:验证用户类型 + 验证Redis
@LoginRequired
public R<String> method1() { ... }
// 只验证JWT,不验证用户类型和Redis
@LoginRequired(checkUserType = false, checkRedisToken = false)
public R<String> method2() { ... }
// 验证用户类型,但不验证Redis
@LoginRequired(checkRedisToken = false)
public R<String> method3() { ... }
文件路径:alien-store-platform/src/main/java/shop/alien/storeplatform/aspect/LoginAspect.java
核心逻辑:
@Slf4j
@Aspect
@Component
@Order(1) // 优先级最高,最先执行
@RequiredArgsConstructor
public class LoginAspect {
private final BaseRedisService baseRedisService;
/**
* 定义切点:所有标注了 @LoginRequired 注解的方法
*/
@Pointcut("@annotation(shop.alien.storeplatform.annotation.LoginRequired)")
public void loginRequiredPointcut() {
}
/**
* 环绕通知:在方法执行前进行登录验证
*/
@Around("loginRequiredPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 获取方法签名和注解
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
// 2. 获取类名和方法名(用于日志)
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = method.getName();
try {
// 3. 从Token中获取用户信息
JSONObject userInfo = JwtUtil.getCurrentUserInfo();
if (userInfo == null) {
log.warn("LoginAspect - 用户未登录或Token无效: {}.{}", className, methodName);
return R.fail("请先登录");
}
// 4. 验证用户类型(如果需要)
if (loginRequired.checkUserType()) {
String userType = userInfo.getString("userType");
if (!"storePlatform".equals(userType)) {
log.warn("LoginAspect - 用户类型不正确: {}.{}, userType={}",
className, methodName, userType);
return R.fail("请先登录");
}
}
// 5. 验证Redis中的Token(如果需要)
if (loginRequired.checkRedisToken()) {
String phone = userInfo.getString("phone");
String redisToken = baseRedisService.getString("store_platform_" + phone);
if (redisToken == null) {
log.warn("LoginAspect - Token已过期: {}.{}, phone={}",
className, methodName, phone);
return R.fail("登录已过期,请重新登录");
}
}
log.debug("LoginAspect - 登录验证通过: {}.{}, userId={}",
className, methodName, userInfo.getString("userId"));
// 6. 执行目标方法
return joinPoint.proceed();
} catch (Exception e) {
log.error("LoginAspect - 验证用户登录状态异常: {}.{}, error={}",
className, methodName, e.getMessage(), e);
return R.fail("请先登录");
}
}
}
关键点:
文件路径:alien-store-platform/src/main/java/shop/alien/storeplatform/controller/NoticeController.java
@Slf4j
@Api(tags = {"web端商户通知管理"})
@RestController
@RequestMapping("/notice")
@RequiredArgsConstructor
public class NoticeController {
private final NoticeService noticeService;
@LoginRequired // ✅ 添加登录验证注解
@ApiOperation("获取系统通知和订单提醒统计")
@GetMapping("/getNoticeStatistics")
public R<JSONObject> getNoticeStatistics(@RequestParam("receiverId") String receiverId) {
log.info("NoticeController.getNoticeStatistics?receiverId={}", receiverId);
try {
JSONObject result = noticeService.getNoticeStatistics(receiverId);
return R.data(result, "查询成功");
} catch (Exception e) {
log.error("NoticeController.getNoticeStatistics ERROR: {}", e.getMessage(), e);
return R.fail(e.getMessage());
}
}
@LoginRequired // ✅ 同样的方式
@ApiOperation("获取通知列表")
@GetMapping("/getNoticeList")
public R<IPage<LifeNoticeVo>> getNoticeList(...) {
// 业务逻辑
}
// 其他接口同理...
}
文件路径:alien-store-platform/src/main/java/shop/alien/storeplatform/controller/StoreManageController.java
@Slf4j
@Api(tags = {"web端商户店铺管理"})
@RestController
@RequestMapping("/storeManage")
@RequiredArgsConstructor
public class StoreManageController {
private final StoreManageService storeManageService;
@LoginRequired // ✅ 添加登录验证注解
@ApiOperation("新增店铺入住申请")
@PostMapping("/applyStore")
public R<StoreInfoVo> applyStore(@RequestBody StoreInfoDto storeInfoDto) {
log.info("StoreManageController.applyStore?storeInfoDto={}", storeInfoDto);
try {
StoreInfoVo result = storeManageService.applyStore(storeInfoDto);
if (result != null) {
return R.data(result, "店铺入住申请已提交");
}
return R.fail("申请失败");
} catch (Exception e) {
log.error("StoreManageController.applyStore ERROR: {}", e.getMessage(), e);
return R.fail(e.getMessage());
}
}
// 其他接口同理...
}
| 实现方式 | Controller代码行数 | 总代码行数(10个接口) |
|---|---|---|
| 手动验证 | ~18行/接口 | ~180行 |
| AOP切面 | ~8行/接口 | ~80行 + 切面类60行 = 140行 |
| 减少代码 | 减少10行/接口 | 减少22%代码 |
| 实现方式 | 需要做的事情 |
|---|---|
| 手动验证 | ❌ 复制粘贴验证代码 + 注入依赖 + 写验证逻辑 |
| AOP切面 | ✅ 添加一个 @LoginRequired 注解 |
✅ LoginRequired.java - 自定义注解
alien-store-platform/src/main/java/shop/alien/storeplatform/annotation/LoginRequired.java✅ LoginAspect.java - 登录验证切面
alien-store-platform/src/main/java/shop/alien/storeplatform/aspect/LoginAspect.java✅ NoticeController.java - 通知管理控制器
BaseRedisService 依赖JwtUtil 导入validateUserLogin() 方法(~40行)@LoginRequired 注解✅ StoreManageController.java - 店铺管理控制器
BaseRedisService 依赖JwtUtil 导入JSONObject 导入validateUserLogin() 方法(~40行)@LoginRequired 注解| 注解 | 作用 |
|---|---|
@Aspect |
标识这是一个切面类 |
@Component |
将切面类注册为Spring Bean |
@Order(1) |
设置切面优先级(数字越小优先级越高) |
@Pointcut |
定义切点表达式 |
@Around |
环绕通知,可以在方法执行前后进行处理 |
@Pointcut("@annotation(shop.alien.storeplatform.annotation.LoginRequired)")
含义:匹配所有标注了 @LoginRequired 注解的方法
其他常见切点表达式:
// 匹配指定包下的所有方法
@Pointcut("execution(* shop.alien.storeplatform.controller..*.*(..))")
// 匹配指定类的所有方法
@Pointcut("within(shop.alien.storeplatform.controller.NoticeController)")
// 组合多个切点
@Pointcut("@annotation(LoginRequired) && within(shop.alien.storeplatform.controller..*)")
| 方法 | 作用 |
|---|---|
getSignature() |
获取方法签名 |
getTarget() |
获取目标对象 |
getArgs() |
获取方法参数 |
proceed() |
执行目标方法 |
proceed(Object[] args) |
用修改后的参数执行目标方法 |
1. 提取Token
↓
JwtUtil.getCurrentUserInfo()
↓
2. 检查用户信息是否为空
↓
if (userInfo == null) → 返回"请先登录"
↓
3. 验证用户类型(如果checkUserType=true)
↓
if (userType != "storePlatform") → 返回"请先登录"
↓
4. 验证Redis中的Token(如果checkRedisToken=true)
↓
if (redisToken == null) → 返回"登录已过期,请重新登录"
↓
5. 验证通过
↓
joinPoint.proceed() → 执行业务方法
| 场景 | 检测点 | 返回提示 |
|---|---|---|
| 未携带Token | userInfo == null | "请先登录" |
| Token无效/过期 | JWT解析失败 | "请先登录" |
| 用户类型错误 | userType != "storePlatform" | "请先登录" |
| Token已被注销 | Redis中Token不存在 | "登录已过期,请重新登录" |
| 验证过程异常 | 捕获Exception | "请先登录" |
| 序号 | 接口名称 | 接口路径 | 请求方式 | 添加注解 |
|---|---|---|---|---|
| 1 | 获取通知统计 | /notice/getNoticeStatistics | GET | ✅ @LoginRequired |
| 2 | 获取通知列表 | /notice/getNoticeList | GET | ✅ @LoginRequired |
| 3 | 标记单个已读 | /notice/markNoticeAsRead | POST | ✅ @LoginRequired |
| 4 | 批量标记已读 | /notice/markAllNoticesAsRead | POST | ✅ @LoginRequired |
| 序号 | 接口名称 | 接口路径 | 请求方式 | 添加注解 |
|---|---|---|---|---|
| 1 | 店铺入住申请 | /storeManage/applyStore | POST | ✅ @LoginRequired |
| 2 | 保存店铺草稿 | /storeManage/saveStoreDraft | POST | ✅ @LoginRequired |
| 3 | 查询店铺草稿 | /storeManage/getStoreDraft | GET | ✅ @LoginRequired |
| 4 | 获取店铺详情 | /storeManage/getStoreDetail | GET | ✅ @LoginRequired |
| 5 | 获取今日收益 | /storeManage/getTodayIncome | GET | ✅ @LoginRequired |
| 6 | 获取今日订单数 | /storeManage/getTodayOrderCount | GET | ✅ @LoginRequired |
合计:10个接口,全部使用 @LoginRequired 注解。
如果业务代码需要获取当前登录用户信息,可以直接调用:
@LoginRequired
@GetMapping("/getUserInfo")
public R<JSONObject> getUserInfo() {
// 获取当前登录用户信息
JSONObject userInfo = JwtUtil.getCurrentUserInfo();
String userId = userInfo.getString("userId");
String phone = userInfo.getString("phone");
String userName = userInfo.getString("userName");
// 业务逻辑...
return R.data(userInfo);
}
通过注解参数灵活配置:
// 场景1:只验证Token存在,不验证用户类型和Redis
@LoginRequired(checkUserType = false, checkRedisToken = false)
@GetMapping("/publicMethod")
public R<String> publicMethod() {
// 适用于公共接口,只需要用户登录即可
}
// 场景2:验证用户类型,但允许Token在Redis中已过期
@LoginRequired(checkRedisToken = false)
@GetMapping("/relaxedMethod")
public R<String> relaxedMethod() {
// 适用于宽松验证场景
}
修改 LoginAspect 支持多种用户类型:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
/**
* 允许的用户类型列表
*/
String[] allowUserTypes() default {"storePlatform"};
}
// 切面中的验证逻辑
if (loginRequired.allowUserTypes().length > 0) {
String userType = userInfo.getString("userType");
boolean allowed = Arrays.asList(loginRequired.allowUserTypes()).contains(userType);
if (!allowed) {
return R.fail("请先登录");
}
}
使用示例:
// 同时允许商户平台和app端商户访问
@LoginRequired(allowUserTypes = {"storePlatform", "store"})
@GetMapping("/sharedMethod")
public R<String> sharedMethod() {
// ...
}
在登录验证的基础上增加权限验证:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
/**
* 需要的权限代码
*/
String[] permissions();
}
@LoginRequired
@RequirePermission(permissions = {"store:manage", "store:edit"})
@PostMapping("/updateStore")
public R<Boolean> updateStore() {
// 需要登录 + 特定权限
}
| 项目 | 性能影响 |
|---|---|
| 切面拦截 | ~0.01ms(极小) |
| JWT解析 | ~0.1-0.5ms |
| Redis查询 | ~1-3ms |
| 总耗时 | ~1-5ms |
结论:AOP切面的性能开销极小,对接口响应时间的影响可以忽略不计。
| 实现方式 | 验证耗时 | 备注 |
|---|---|---|
| 手动验证 | ~1-5ms | 直接调用验证方法 |
| AOP切面 | ~1-5ms | 切面拦截耗时可忽略 |
结论:两种方式的性能基本一致,AOP切面没有额外的性能损失。
2025-11-13 10:30:15 [DEBUG] LoginAspect - 开始验证登录状态: NoticeController.getNoticeStatistics
2025-11-13 10:30:15 [DEBUG] LoginAspect - 登录验证通过: NoticeController.getNoticeStatistics, userId=123
2025-11-13 10:30:15 [INFO] NoticeController.getNoticeStatistics?receiverId=store_18241052019
2025-11-13 10:30:20 [DEBUG] LoginAspect - 开始验证登录状态: NoticeController.getNoticeStatistics
2025-11-13 10:30:20 [WARN] LoginAspect - 用户未登录或Token无效: NoticeController.getNoticeStatistics
2025-11-13 10:30:25 [DEBUG] LoginAspect - 开始验证登录状态: NoticeController.getNoticeStatistics
2025-11-13 10:30:25 [WARN] LoginAspect - 用户类型不正确: NoticeController.getNoticeStatistics, userType=user
2025-11-13 10:30:30 [DEBUG] LoginAspect - 开始验证登录状态: NoticeController.getNoticeStatistics
2025-11-13 10:30:30 [WARN] LoginAspect - Token已过期: NoticeController.getNoticeStatistics, phone=15242687180
@SpringBootTest
@RunWith(SpringRunner.class)
public class LoginAspectTest {
@Autowired
private NoticeController noticeController;
@Test
public void testWithValidToken() {
// 模拟携带有效Token的请求
// 验证接口返回成功
}
@Test
public void testWithoutToken() {
// 模拟未携带Token的请求
// 验证接口返回"请先登录"
}
@Test
public void testWithExpiredToken() {
// 模拟携带过期Token的请求
// 验证接口返回"登录已过期,请重新登录"
}
@Test
public void testWithWrongUserType() {
// 模拟使用非storePlatform类型用户的Token
// 验证接口返回"请先登录"
}
}
A:检查以下几点:
@Aspect 和 @Component 注解spring-boot-starter-aop 依赖A:
log.debug 输出调试信息A:使用 @Order 注解:
@Order(1) // 数字越小,优先级越高
public class LoginAspect { ... }
@Order(2)
public class LoggingAspect { ... }
A:不添加 @LoginRequired 注解即可:
// 这个接口不需要登录
@GetMapping("/publicMethod")
public R<String> publicMethod() {
// ...
}
A:不会。Swagger只读取接口的注解信息,不执行切面逻辑。
A:可以使用 ThreadLocal 或 Spring 的 RequestContextHolder:
// 在切面中设置用户信息
UserContext.setCurrentUser(userInfo);
// 在业务代码中获取
JSONObject user = UserContext.getCurrentUser();
@LoginRequired、@RequirePermission@Check、@Auth@Order(1) // 登录验证切面
@Order(2) // 权限验证切面
@Order(3) // 日志记录切面
@Order(4) // 性能监控切面
DEBUG:验证过程详细信息WARN:验证失败场景ERROR:验证过程异常| 文件 | 路径 | 代码量 |
|---|---|---|
| LoginRequired.java | annotation/LoginRequired.java | ~20行 |
| LoginAspect.java | aspect/LoginAspect.java | ~100行 |
| 文件 | 修改内容 | 减少代码量 |
|---|---|---|
| NoticeController.java | 移除手动验证逻辑,添加注解 | -60行 |
| StoreManageController.java | 移除手动验证逻辑,添加注解 | -80行 |
重大改进:
@LoginRequired 注解LoginAspect 切面类NoticeController(移除60行重复代码)StoreManageController(移除80行重复代码)涉及文件:
LoginRequired.java - 新增LoginAspect.java - 新增NoticeController.java - 重构StoreManageController.java - 重构USER_AUTHENTICATION_GUIDE_AOP.md - 新增(本文档)开发人员:ssk
文档版本:v2.0(AOP切面版)
最后更新:2025-11-13
维护人员:ssk
@LoginRequired
@GetMapping("/getData")
public R<String> getData() {
return R.data("data");
}
@LoginRequired
@GetMapping("/getCurrentUser")
public R<JSONObject> getCurrentUser() {
JSONObject userInfo = JwtUtil.getCurrentUserInfo();
return R.data(userInfo);
}
@LoginRequired(checkRedisToken = false)
@GetMapping("/relaxedCheck")
public R<String> relaxedCheck() {
return R.data("ok");
}
// 不添加@LoginRequired注解
@GetMapping("/publicApi")
public R<String> publicApi() {
return R.data("public data");
}
🎉 大功告成! 采用AOP切面方式,代码更优雅、更易维护!