SpringBoot最佳实践之 - 使用AOP记录操作日志

1. 前言

本篇博客是个人在工作中遇到的需求。针对此需求,开发了具体的实现代码。并不是普适的记录操作日志的方式。以阅读本篇博客的朋友,可以参考此篇博客中记录日志的方式,可能会对你有些许帮助和启发。

2. 需求描述

有一个后台管理系统,此系统具有不同角色的用户,比如管理员、操作员、审计员等。当这些角色的用户登录到系统中,以及其在系统中所触发的 <增删改> 操作。我都想记录操作日志。然后存储到数据库中。比如记录如下:

数据库中有了数据,就可以在查询出来显示到页面上。对于一个业务敏感的后台管理系统来说,就可以通过这里查看哪些用户操作了什么功能。操作的结果是成功还是失败,如果操作失败,失败的原因是什么。如下:

3. 需求实现

3.1 准备工作

3.1.1 导入依赖
     <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.2</version>
        </dependency>
3.1.2 数据库脚本

用户表 t_user

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for t_user
-- ----------------------------
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user`  (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `user_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名',
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '密码',
  `phone` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '手机号',
  `email` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '邮箱',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',
  `status` tinyint(4) NULL DEFAULT 0 COMMENT '用户状态(0:可用;1:禁用)',
  `delete_flag` tinyint(4) NULL DEFAULT NULL COMMENT '删除标记(0:未删除;1:已删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of t_user
-- ----------------------------
INSERT INTO `t_user` VALUES (1, '张三', '123456', '18178526349', '123@qq.com', '2024-10-29 08:42:34', '2024-10-29 08:42:37', 0, 0);

SET FOREIGN_KEY_CHECKS = 1;

操作日志表 t_system_log

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for t_system_log
-- ----------------------------
DROP TABLE IF EXISTS `t_system_log`;
CREATE TABLE `t_system_log`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `operate_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '触发的动作',
  `operate_user_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '操作用户名',
  `operate_time` varchar(40) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '操作时间',
  `operate_result` tinyint(4) NULL DEFAULT NULL COMMENT '0成功/1失败',
  `operate_fail_reason` text CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '操作失败原因',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 800 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of t_system_log
-- ----------------------------
INSERT INTO `t_system_log` VALUES (792, '登录', '张三', '2024-10-29 10:06:09', 0, NULL);
INSERT INTO `t_system_log` VALUES (793, '登录', '张三', '2024-10-29 10:07:13', 1, '用户名或密码错误');
INSERT INTO `t_system_log` VALUES (794, '登录', '张三', '2024-10-29 10:09:22', 1, '用户名或密码错误');
INSERT INTO `t_system_log` VALUES (795, '登录', '张三', '2024-10-29 10:11:31', 1, '用户名或密码错误');
INSERT INTO `t_system_log` VALUES (796, '添加商品', '张三', '2024-10-29 10:19:11', 0, NULL);
INSERT INTO `t_system_log` VALUES (797, '添加商品', '张三', '2024-10-29 10:19:32', 1, '商品已存在');
INSERT INTO `t_system_log` VALUES (798, '下架商品', '张三', '2024-10-29 10:41:58', 0, NULL);
INSERT INTO `t_system_log` VALUES (799, '下架商品', '张三', '2024-10-29 10:42:22', 1, '商品正在发货中,无法下架');

SET FOREIGN_KEY_CHECKS = 1;

3.2 需要的组件说明

1)自定义注解 @Operation:把自定义注解标注在Controller方法上,后续通过切面识别Controller方法上标注的注解,以及注解的value值,从而实现记录操作日志功能;

2)切面类 LogAspect: 识别标注有@Operation注解的Controller方法,在方法执行过程中进行切面操作;

3)日志实体类 SystemLog:记录日志,对应的实体类,需要把记录的信息保存到数据库中;

  1. 用户实体类 User: 用户实体类;

5)业务异常类:自定义的异常类;

6)统一错误码枚举类:自定义的错误码枚举类,把项目中出现的错误码统一存放在此处,便于管理;

3.3 组件代码

3.3.1 自定义注解 @Operation
package com.shg.annotation;

import java.lang.annotation.Documented;
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)
@Documented
public @interface Operation {
    
    String value();
}
3.3.2 切面类 LogAspect
package com.shg.aspect;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.shg.annotation.Operation;
import com.shg.model.pojo.SystemLog;
import com.shg.model.pojo.User;
import com.shg.service.RecordLogService;
import com.shg.service.UserService;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.Objects;

@Component
@Aspect
public class LogAspect {

    private final UserService userService;
    private final RecordLogService recordLogService;

    public LogAspect(UserService userService, RecordLogService recordLogService) {
        this.userService = userService;
        this.recordLogService = recordLogService;
    }

    @Pointcut(value = "@annotation(com.shg.annotation.Operation)")
    private void pointCut() {
    }

    @Around(value = "pointCut()")
    public Object recordLog(ProceedingJoinPoint pjp) throws Throwable {

        // 拿到请求对象Request
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = requestAttributes.getRequest();

        // 通过request获取请求头中的登录用户[此处是模拟直接在请求头中携带一个用户id,真实开发是在请求头中携带一个token,然后通过token去redis中查询用户信息,包括用户权限信息等]
        String userId = request.getHeader("userId");

        // 通过userId 去数据库中查询用户信息
        User userFromDB = userService.getById(userId);

        // 拿到方法上标注的自定义注解的value值,这样就可以知道当前这个用户是在做什么操作了
        MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
        Method method = methodSignature.getMethod();
        Operation annotation = method.getAnnotation(Operation.class);
        String value;
        Object result = null;
        if (!Objects.isNull(annotation)) {
            value = annotation.value();
            // 当你在某个方法上标注了 @Operation自定义注解,并且给这个注解的value进行合法赋值后,才记录日志(比如增删改操作),而对于查询方法,一般不需要在Controller方法上标注@Operation注解
            if (StrUtil.isNotBlank(value)) {
                SystemLog systemLog = new SystemLog();
                systemLog.setOperateName(value);
                systemLog.setOperateUserName(userFromDB.getUserName());
                systemLog.setOperateTime(DateUtil.formatDateTime(new Date()));
                try {
                    result = pjp.proceed();
                    systemLog.setOperateResult(0);
                    recordLogService.save(systemLog);
                } catch (Exception e) {
                    systemLog.setOperateResult(1);
                    systemLog.setOperateFailReason(e.getMessage());
                    recordLogService.save(systemLog);
                    throw e;
                } finally {
                    System.out.println("finally...");
                }
            }
        }
        return result;
    }
}
3.3.3 日志实体类
package com.shg.model.pojo;

import java.io.Serializable;

import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@TableName("t_system_log")
public class SystemLog implements Serializable {

    private Integer id;

    private String operateName;

    private String operateUserName;

    private String operateTime;

    private String operateFailReason;

    /**
     * 0成功/1失败
     */
    @ApiModelProperty("0成功/1失败")
    private Integer operateResult;
    
}
3.3.4 用户实体类
package com.shg.model.pojo;

import java.time.LocalDateTime;
import java.util.Date;

import java.io.Serializable;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("t_user")
public class User implements Serializable {
    private static final long serialVersionUID = -45223488720491550L;
    /**
     * 自增主键
     */
    @TableId
    private Integer id;

    /**
     * 用户名
     */
    private String userName;
    /**
     * 密码
     */
    private String password;
    /**
     * 手机号
     */
    private String phone;
    /**
     * 邮箱
     */
    private String email;
    /**
     * 创建时间
     */
    private LocalDateTime createTime;
    /**
     * 修改时间
     */
    private LocalDateTime updateTime;
    /**
     * 用户状态(0:可用;1:禁用)
     */
    private Integer status;
    /**
     * 删除标记(0:未删除;1:已删除)
     */
    private Integer deleteFlag;

}
3.3.5 业务异常类
package com.shg.exception;

import com.shg.common.ResponseCodeEnum;
import lombok.Data;

@Data
public class BizException extends RuntimeException{
    private Integer code;
    private String message;

    public BizException(Integer code, String message) {
        super(message);
        this.code = code;
        this.message = message;
    }

    public BizException(ResponseCodeEnum responseCodeEnum) {
        super(responseCodeEnum.getMessage());
        this.code = responseCodeEnum.getCode();
        this.message = responseCodeEnum.getMessage();
    }

}
3.3.6统一错误码枚举类
package com.shg.common;

public enum ResponseCodeEnum {

    SUCCESS(0, "success"),
    SYSTEM_EXCEPTION(500, "System internal exception"),
    USERNAME_OR_PASSWORD_FAIL(1001, "用户名或密码错误"),
    USER_NOT_EXISTS(1002,"用户不存在"), 
    
    GOODS_ID_EXISTS(2001, "商品已存在"),
    DELETE_GOODS_FAIL(2001, "商品正在发货中,无法下架");
    private final int code;
    private final String message;

    ResponseCodeEnum(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

}
3.2.7 Controller类
package com.shg.controller;

import com.shg.annotation.Operation;
import com.shg.common.ResponseCodeEnum;
import com.shg.common.ResultMessage;
import com.shg.exception.BizException;
import com.shg.model.pojo.User;
import com.shg.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @Autowired
    private UserService userService;

    @GetMapping("/test1")
    public ResultMessage<String> test1() {
        return ResultMessage.success("这是测试接口...");
    }

    @Operation(value = "登录")
    @GetMapping(value = "/login")
    public ResultMessage login(Integer id) {
        User user = userService.getById(1);
        if (user.getId() == 1) {
            throw new BizException(ResponseCodeEnum.USERNAME_OR_PASSWORD_FAIL);
        }
        return ResultMessage.success("登录成功", user);
    }

    @Operation(value = "添加商品")
    @PostMapping(value = "/addGoods")
    public ResultMessage addGoods(@RequestParam Integer goodsId) {
        if (goodsId == 2) {
            throw new BizException(ResponseCodeEnum.GOODS_ID_EXISTS);
        }
        return ResultMessage.success("商品添加成功", "模拟添加商品成功");
    }

    @Operation(value = "下架商品")
    @PostMapping(value = "/deleteGoods")
    public ResultMessage deleteGoods(@RequestParam Integer goodsId) {
        if (goodsId == 4) {
            throw new BizException(ResponseCodeEnum.DELETE_GOODS_FAIL);
        }
        return ResultMessage.success("商品下架成功", "模拟商品下架成功");
    }
}

5. 其他

具体代码示例参考:springboot-best-practice: 初次提交

如果此篇文章对你有帮助,感谢点个赞~~

相关推荐
P7进阶路19 分钟前
Tomcat异常日志中文乱码怎么解决
java·tomcat·firefox
Ai 编码助手1 小时前
在 Go 语言中如何高效地处理集合
开发语言·后端·golang
小丁爱养花1 小时前
Spring MVC:HTTP 请求的参数传递2.0
java·后端·spring
CodeClimb1 小时前
【华为OD-E卷 - 第k个排列 100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
等一场春雨1 小时前
Java设计模式 九 桥接模式 (Bridge Pattern)
java·设计模式·桥接模式
Channing Lewis1 小时前
什么是 Flask 的蓝图(Blueprint)
后端·python·flask
带刺的坐椅1 小时前
[Java] Solon 框架的三大核心组件之一插件扩展体系
java·ioc·solon·plugin·aop·handler
不惑_2 小时前
深度学习 · 手撕 DeepLearning4J ,用Java实现手写数字识别 (附UI效果展示)
java·深度学习·ui
费曼乐园2 小时前
Kafka中bin目录下面kafka-run-class.sh脚本中的JAVA_HOME
java·kafka
轩辕烨瑾3 小时前
C#语言的区块链
开发语言·后端·golang