如何使用“自定义注解+Spring AOP+策略模式”记录多端登录日志

一、开发背景

对于多数B/C商业模式的项目来说,都具备多端登录的场景。举个例子,上个公司做的跑腿业务,就有这么几种:用户端手机号验证码登录、用户端用户名密码登录、用户端微信小程序登录、骑手端手机号验证码登录、商户端用户名密码登录、总后台用户名密码登录等等。如果让你去做一个记录多种用户类型加多端登录日志的功能,你会怎么设计,在接口方法的最后边手动添加吗?这明显不是一个好主意,我们可以结合自定义注解+AOP+策略模式的方式,去用切面记录一下!

二、初始化注解和基础类转换接口

1、基础转换接口

(LoginConvert)

策略模式的基础接口,不同登录接口的入参对象可以实现这个接口,然后重载这个convert方法,将入参中的参数收集到LoginLogVo这个类中。

public interface LoginConvert {

    LoginLogVo convert(PARAM param);

}

2、接口统一入参接收

(LoginLogVo.class)

用来记录入参时的参数

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
public class LoginLogVo {

    @ApiModelProperty("登录名")
    private String userName;

    @ApiModelProperty("验证码、小程序Code、密码、OpenId、、、")
    private String codeOrPwdOrOpenId;

}

3、自定义注解

(LoginLogAnno)

注解中定义用户类型、操作类型和转换入参对象

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

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

    /**
     * 用户类型
     * [用户,小程序用户,骑手,管理员]
     */
    String userType() default "";

    /**
     * 操作类型
     * [1登录;2退出]
     */
    int operationType() default 1;

    /**
     * 转换入参对象
     */
    Class<? extends LoginConvert> convert();

}

4、定义不同登录接口的入参

接口的入参对象,实现了之前定义的基础接口,然后重载了convert方法,将对象中的入参提取出来放到LoginLogVo中,方便之后AOP切面中使用。

  1. APP用户端手机号验证码登录(AppLoginRequest)
@Data
public class AppLoginRequest implements LoginConvert {

    @ApiModelProperty(value = "登录手机号")
    @NotBlank(message = "手机号必传")
    private String phone;

    @ApiModelProperty(value = "验证码")
    @NotBlank(message = "验证码必传")
    private String code;

    @Override
    public LoginLogVo convert(AppLoginRequest request) {
        LoginLogVo loginLogVo = new LoginLogVo();
        loginLogVo.setUserName(request.getPhone());
        loginLogVo.setCodeOrPwdOrOpenId(request.getCode());
        return loginLogVo;
    }

}
  1. 用户端微信小程序登录(WxMiniLoginRequest)
@Data
public class WxMiniLoginRequest implements LoginConvert {

    @NotBlank(message = "微信登录Code必传")
    @ApiModelProperty("微信登录Code")
    private String code;

    @ApiModelProperty("openId")
    private String openId;

    @Override
    public LoginLogVo convert(WxMiniLoginRequest wxMiniLoginRequest) {
        LoginLogVo loginLogVo = new LoginLogVo();
        loginLogVo.setUserName(wxMiniLoginRequest.getCode());
        loginLogVo.setCodeOrPwdOrOpenId(wxMiniLoginRequest.getOpenId());
        return loginLogVo;
    }

}
  1. Web端管理员登录(WebLoginRequest)
@Data
public class WebLoginRequest implements LoginConvert {

    @NotBlank(message = "用户名必传")
    @ApiModelProperty(value = "用户名")
    private String userName;

    @NotBlank(message = "密码必传")
    @ApiModelProperty(value = "密码")
    private String password;

    @Override
    public LoginLogVo convert(WebLoginRequest webLoginRequest) {
        LoginLogVo loginLogVo = new LoginLogVo();
        loginLogVo.setUserName(webLoginRequest.getUserName());
        loginLogVo.setCodeOrPwdOrOpenId(webLoginRequest.getPassword());
        return loginLogVo;
    }

}

三、自定义注解使用

1、用户端手机号验证码登录

Controller

	@PostMapping("/user/phone/and/code/login")
    @ApiOperation(value = "用户-手机号验证码登录")
    public ResultEntity userPhoneAndCodeLogin(@Validated AppLoginRequest appLoginRequest) {
        return ResultEntity.success(loginService.userPhoneAndCodeLoginService(appLoginRequest));
    }

Service

	/**
     * 用户-手机号验证码登录
     *
     * @return 登录返回值
     */
    @LoginLogAnno(userType = Constant.USER, operationType = 1, convert = AppLoginRequest.class)
    public CommonLoginResponse userPhoneAndCodeLoginService(AppLoginRequest appLoginRequest) {
        User user = userMapper.selectOne();
        //判断用户状态抛出异常
        if (ObjectUtils.isNull(user)) {
            throw new BadRequestAlertException("账号不存在或已被删除或已被封禁");
        }
        //登录逻辑
        //组装返回值
        return response;
    }

2、用户端微信小程序登录

Controller

	@PostMapping("/user/wx/check")
    @ApiOperation(value = "微信小程序登录")
    public ResultEntity wxMiniCheck(@RequestBody WxMiniLoginRequest request) throws JSONException {
        return ResultEntity.success(loginService.wxMiniCheckService(request));
    }

Service

/**
 * 微信直接登录(如果已有账号且绑定微信)
 *
 * @return 返回参数
 */
@LoginLogAnno(userType = Constant.WX_USER, operationType = 1, convert = WxMiniLoginRequest.class)
public CommonLoginResponse wxMiniCheckService(WxMiniLoginRequest wxMiniLoginRequest) {
  //登录逻辑
  //组装返回值
  return response;
}

3、Web端管理员登录

Controller

	@PostMapping("/admin/password/login")
    @ApiOperation(value = "管理员-账号密码登录")
    public ResultEntity adminPasswordLogin(@Validated WebLoginRequest webLoginRequest) {
        return ResultEntity.success(loginService.adminPasswordLogin(webLoginRequest));
    }

Service

/**
 * 管理员登录
 *
 * @return 返回值
 */
@LoginLogAnno(userType = Constant.ADMIN, operationType = 1, convert = WebLoginRequest.class)
public CommonLoginResponse adminPasswordLogin(WebLoginRequest webLoginRequest) {
  //登录逻辑
  //组装返回值
  return response;
}

四、定义AOP切面类存储日志

1、创建登录日志记录表(ES同理)

(security_log)

CREATE TABLE `security_log` (
  `id` bigint NOT NULL COMMENT '主键ID',
  `log_type` tinyint DEFAULT NULL COMMENT '记录类型 (0登录账户;1登出账户;3异常情况)',
  `user_type` varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户类型 (普通用户=USER、微信用户=WX_USER、管理员=ADMIN)',
  `user_id` bigint DEFAULT NULL COMMENT '用户ID',
  `user_name` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户名',
  `description` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '详细描述',
  `login_ip` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '登录IP',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='日志记录表';

2、定义切面类及切点

主要的核心有这么几点:

  1. @Aspect定义切面
  2. 定义一个线程池threadPoolExecutor,顺序记录登录日志顺序,减少切面的并发压力
  3. @Pointcut定义一个切点,以指定注解的方式去切入
  4. 定义环绕切入点@Around("logPointcut()"),在执行登录方法前获取自定义注解内容,执行方法后获取方法返回值
  5. 定义异常捕捉@AfterThrowing(pointcut = "logPointcut()", throwing = "badRequestAlertException"),当登录方法出现报错时,会抛出指定异常,然后被拦截到并保存到日志记录表中
  6. 切面类实现Ordered类,定义切面的执行优先级
@Slf4j
@Aspect
@Component
@AllArgsConstructor
public class LogService implements Ordered {

    UserMapper userMapper;
    AdminMapper adminMapper;
    SecurityLogMapper securityLogMapper;

    private final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            16, 200, 1, TimeUnit.SECONDS, new LinkedBlockingDeque<>(Integer.MAX_VALUE)
    );
    
    /**
     * 切点
     */
    @Pointcut(value = "@annotation(com.mall.common.aop.annotation.LogRecordAnno)")
    public void logPointcut() {}

    /**
     * 执行器
     *
     * @param joinPoint 切入点
     */
    @Around("logPointcut()")
    public Object saveLoginSecurity(ProceedingJoinPoint joinPoint) throws Throwable {
        //获取方法属性
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        LogRecordAnno logRecordAnno = method.getAnnotation(LogRecordAnno.class);
        int operationType = logRecordAnno.operationType();
        String userType = logRecordAnno.userType();
        //执行方法,获取返回值
        UserLoginResponse userLoginResponse = new UserLoginResponse();
        //用户退出
        if (2 == operationType) {
            joinPoint.proceed();
        } else {
            userLoginResponse = (UserLoginResponse) joinPoint.proceed();
        }
        //获取请求IP
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        String ip = HttpUtils.getRemoteHost(request);
        //组装参数值
        Long userId = 1L;
        if (ObjectUtils.isNotEmpty(userLoginResponse)) {
            userId = userLoginResponse.getId();
        } else {
            //退出功能,从Token信息中取UserId
            Optional userModelOptional = SecurityUtils.getCurrentUserModel();
            if (userModelOptional.isPresent()) {
                userId = userModelOptional.get().getId();
            }
        }
        //线程池去执行保存登录日志
        Long finalUserId = userId;
        threadPoolExecutor.execute(() -> {
            String userName = "用户名称异常";
            //用户名称、等级名称赋值
            switch (userType) {
                case Constant.USER:
                    User user = userMapper.selectById(finalUserId);
                    if (ObjectUtils.isNotEmpty(user)) {
                        if (StringUtils.isNotBlank(user.getUserName())) {
                            userName = user.getUserName();
                        } else {
                            userName = user.getNickName();
                        }
                    }
                    break;
                case Constant.ADMIN:
                    Admin admin = adminMapper.selectById(finalUserId);
                    if (ObjectUtils.isNotEmpty(admin)) {
                        userName = admin.getUserName();
                    }
                    break;
            }
            //记录操作日志
            SecurityLog securityLog = new SecurityLog();
            securityLog.setUserType(userType);
            securityLog.setUserId(finalUserId);
            securityLog.setUserName(userName);
            securityLog.setLoginIp(ip);
            if (operationType == 1) {
                securityLog.setLogType(0);
                securityLog.setDescription("成功登录");
            } else {
                securityLog.setLogType(1);
                securityLog.setDescription("登出账户");
            }
            securityLogMapper.insert(securityLog);
        });
        return userLoginResponse;
    }

    @AfterThrowing(pointcut = "logPointcut()", throwing = "badRequestAlertException")
    public void afterThrowable(JoinPoint joinPoint, BadRequestAlertException badRequestAlertException) {
        try {
            //获取方法属性
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            Method method = methodSignature.getMethod();
            LogRecordAnno logRecordAnno = method.getAnnotation(LogRecordAnno.class);
            Class<? extends LoginConvert> convert = logRecordAnno.convert();
            LoginConvert logLoginConvert = convert.newInstance();
            LoginLogVo loginLogVo = logLoginConvert.convert(joinPoint.getArgs()[0]);
            String userType = logRecordAnno.userType();
            SecurityLog securityLog = new SecurityLog();
            securityLog.setUserType(userType);
            securityLog.setUserId(1L);
            securityLog.setUserName(loginLogVo.getUserName() + ":" + loginLogVo.getPassword());
            securityLog.setLogType(3);
            HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
            String ip = HttpUtils.getRemoteHost(request);
            securityLog.setLoginIp(ip);
            securityLog.setDescription("登录或退出失败,失败原因为:" + (badRequestAlertException.getDetail()));
            securityLogMapper.insert(securityLog);
        } catch (Exception e) {
            log.error("登录或退出抛异常,AOP切面记录记录失败!");
        }
    }

    @Override
    public int getOrder() {
        return 1;
    }

}

五、总结

至此,使用自定义注解+AOP+策略模式记录多端登录日志的功能已经开发完成,核心技术点在于如何在不影响主线业务的同时,用切面去异步地保存登录的日志。并且还是一个多用户类型、多终端的登录方式。其实,在其他相同场景的业务中,也可以这么记录日志或做其他操作。比如,多端创建订单多商户处理订单多层级审批工单等业务场景,都可以使用这种方式,去做相同的处理。你在公司中也遇到过类似的场景吗,去试一下吧!

所有你想象的一切,皆是现实!!!
展开阅读全文

更新时间:2024-08-27

标签:注解   切面   日志   手机号   用户端   接口   定义   策略   类型   模式   方法   用户

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top