Skip to content




一、背景

由于业务体系不断迭代和功能的升级,开发人员往往需要对业务开发功能不断升级改造,同时产生了各种各样的问题,例如:

  • 生产环境出现了业务功能异常问题,无法复现用户是如何操作对应的功能产生的问题情况。
  • 微服务中服务与服务之间的调用,链路产生的异常问题,导致开发人员排查问题过程异常艰难。
  • 上下游系统调用中,没有明确的日志记录,也会导致排查过程异常艰难无从下手。 所以,接口操作日志功能就体现了尤为重要表现。

二、作用

  • 开发人员: 主要帮组开发人员快速定位业务功能异常问题, 同时记录用户操作行为有利于后期做大数据分析用户行为。
  • 运营人员: 主要帮组运营人员可以更直观看出操作功能被那些用户进行操作, 可以防止重复操作功能,同时减少不必要的争议。

三、使用范围

操作日志使用范围主要作用于:

  • 新增
  • 删除
  • 修改
  • 状态的更新
  • 查询(使用的很少,通常不建议使用在查询,除了比较重要的查询功能, 例如:第三方支付回调

四、SpringBoot 实现操作日志功能

1. 数据库-表结构设计

sql

-- auto-generated definition
create table st_opt_log
(
  id           int auto_increment comment '主键id' primary key,
  opt_url      text null comment '操作URL',
  opt_req      text null comment '操作请求参数',
  opt_res      text null comment '操作响应参数',
  opt_module   varchar(32) not null comment '操作模块 ',
  biz_id       varchar(32) null comment '业务ID',
  opt_user_id  varchar(32) null comment '操作人id',
  opt_username varchar(64) null comment '操作人姓名',
  opt_content  text null comment '内容',
  ip           varchar(64) null comment 'ip',
  `time`       bigint null comment '耗时',
  log_success  tinyint(1) null comment '执行是否成功:  0不成功 1成功',
  create_time  datetime    not null comment '创建时间',
  update_time  datetime null comment '更新时间'
) comment '操作日志表' collate = utf8mb4_unicode_ci;

create index idx_biz_id on st_opt_log (biz_id) comment '业务ID索引';
create index idx_create_time on st_opt_log (create_time desc) comment '创建时间索引';
create index idx_opt_module on st_opt_log (opt_module) comment '操作模块索引';
create index idx_user_id on st_opt_log (opt_user_id) comment '操作用户索引';

2. 项目工程依赖管理

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.calvin</groupId>
  <artifactId>spring-boot-example-09-operation-log</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <name>spring-boot-example-09-operation-log</name>
  <description>Spring Boot 操作日志记录</description>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.13</version>
  </parent>

  <properties>
    <java.version>1.8</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <spring-boot.version>2.6.13</spring-boot.version>
  </properties>

  <repositories>
    <!-- 使用阿里云仓库 -->
    <repository>
      <id>central</id>
      <name>aliyun maven</name>
      <url>https://maven.aliyun.com/nexus/content/groups/public</url>
      <layout>default</layout>
      <!-- 开启使用发布版的构建下载 -->
      <releases>
        <enabled>true</enabled>
      </releases>
      <!-- 不开启使用快照版的构建下载 -->
      <snapshots>
        <enabled>false</enabled>
      </snapshots>
    </repository>
  </repositories>


  <dependencies>

    <!-- START: Web Starter 启动器 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- START: END Starter 启动器 -->

    <!-- START: TEST 单元测试 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <!-- END: TEST 单元测试 -->

    <!-- START: mybatis-plus -->
    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-boot-starter</artifactId>
      <version>3.5.4.1</version>
    </dependency>

    <!-- mysql 驱动 -->
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
    </dependency>
    <!-- END: mybatis-plus -->

    <!-- START: lombok  -->
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
    </dependency>
    <!-- END: lombok -->

    <!-- START: hutool 糊涂工具类  -->
    <dependency>
      <groupId>cn.hutool</groupId>
      <artifactId>hutool-all</artifactId>
      <version>5.8.25</version>
      <scope>compile</scope>
    </dependency>
    <!-- END: hutool 糊涂工具类  -->

    <!-- START: aspectj 切面  -->
    <dependency>
      <groupId>org.aspectj</groupId>
      <artifactId>aspectjweaver</artifactId>
      <version>1.9.7</version>
    </dependency>
    <!-- END: aspectj 切面  -->

    <!-- START: JSON 相关  -->
    <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>2.0.48</version>
    </dependency>

    <dependency>
      <groupId>org.json</groupId>
      <artifactId>json</artifactId>
      <version>20210307</version>
    </dependency>

    <dependency>
      <groupId>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.9.1</version>
    </dependency>
    <!-- END: JSON 相关  -->

    <!-- START: Validation 验证  -->
    <dependency>
      <groupId>javax.validation</groupId>
      <artifactId>validation-api</artifactId>
      <version>2.0.1.Final</version>
    </dependency>
    <dependency>
      <groupId>jakarta.validation</groupId>
      <artifactId>jakarta.validation-api</artifactId>
      <version>3.0.2</version>
    </dependency>
    <!-- END: Validation 验证  -->

    <!-- START: Mapstruct 转换器 -->
    <dependency>
      <groupId>org.mapstruct</groupId>
      <artifactId>mapstruct</artifactId>
      <version>1.5.5.Final</version>
    </dependency>
    <dependency>
      <groupId>org.mapstruct</groupId>
      <artifactId>mapstruct-processor</artifactId>
      <version>1.5.5.Final</version>
    </dependency>
    <!-- END: Mapstruct 转换器 -->

  </dependencies>


  <dependencyManagement>
    <dependencies>
      <!--  spring-boot-dependencies 专门管理相关的版本依赖 -->
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>${spring-boot.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <build>
    <plugins>
      <!-- 打包跳过测试 -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <configuration>
          <skip>true</skip>
        </configuration>
      </plugin>

      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
        <configuration>
          <source>1.8</source>
          <target>1.8</target>
          <encoding>UTF-8</encoding>
          <annotationProcessorPaths>
            <path>
              <groupId>org.mapstruct</groupId>
              <artifactId>mapstruct-processor</artifactId>
              <version>1.5.3.Final</version>
            </path>
            <path>
              <groupId>org.projectlombok</groupId>
              <artifactId>lombok-mapstruct-binding</artifactId>
              <version>0.2.0</version>
            </path>
          </annotationProcessorPaths>
        </configuration>
      </plugin>

      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <version>${spring-boot.version}</version>
        <configuration>
          <mainClass>com.calvin.spring.boot.example.operation.log.AppExample09</mainClass>
          <!-- 没有主清单属性,需要注释以下代码-->
          <!-- <skip>true</skip> -->
        </configuration>
        <executions>
          <execution>
            <id>repackage</id>
            <goals>
              <goal>repackage</goal>
            </goals>
          </execution>
        </executions>
      </plugin>

    </plugins>
  </build>

</project>

3. Spring Boot 四层架构模型-生成CRUD功能

  • 模型层(Model)(领域驱动模型)-> domain
    • vo 响应体
    • dto 请求体
    • entity 实体类

当前只展示实体类

java
package com.calvin.spring.boot.example.operation.log.annotation;

import com.calvin.spring.boot.example.operation.log.constants.OptLogModule;
import com.calvin.spring.boot.example.operation.log.constants.OptTypeConstant;

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;


/**
 * 操作日志-注解
 *
 * @author calvin
 * @date 2024/04/25
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OptLog {

  /**
   * 日志内容
   *
   * @return {@link String}
   */
  String content() default "";

  /**
   * 匹配字段
   *
   * @return {@link String}
   */
  String matchField() default "";

  /**
   * 匹配字段值转描述
   *
   * @return {@link String}
   */
  String matchFieldValToDesc() default "";

  /**
   * 操作模块
   *
   * @return {@link String}
   */
  String optModule() default OptLogModule.DEFAULT;

  /**
   * 操作日志类型
   *
   * @return (1查询,2添加,3修改,4删除, 5批量操作)
   */
  int operateType() default OptTypeConstant.DEFAULT;

  /**
   * 业务ID(唯一标识)
   *
   * @return {@link String}
   */
  String bizId() default "";
}
  • 持久层(Repository/DAO) -> Mapper
java
package com.calvin.spring.boot.example.operation.log.mapper;

import com.calvin.spring.boot.example.operation.log.domain.entity.OptLog;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * 操作日志表(OptLog)-数据库-访问层
 *
 * @author Calvin
 * @since 2024-05-01 14:51:11
 */
public interface OptLogMapper extends BaseMapper<OptLog> {


}
  • 服务层(Service)
    • service 服务接口
    • serviceImpl 服务接口实现
java

package com.calvin.spring.boot.example.operation.log.service;

import com.calvin.spring.boot.example.operation.log.domain.dto.OptLogCreateDTO;
import com.calvin.spring.boot.example.operation.log.domain.dto.OptLogEditDTO;
import com.calvin.spring.boot.example.operation.log.domain.entity.OptLog;
import com.baomidou.mybatisplus.extension.service.IService;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.calvin.spring.boot.example.operation.log.domain.vo.OptLogDetailVO;

import java.util.List;

/**
 * 操作日志表(OptLog)-服务接口
 *
 * @author Calvin
 * @since 2024-05-01 14:51:11
 */
public interface IOptLogService extends IService<OptLog> {

  /**
   * 新增
   *
   * @param createDTO 新增-请求体
   * @return 是否成功
   */
  boolean create(OptLogCreateDTO createDTO);

  /**
   * 修改
   *
   * @param editDTO 编辑-请求体
   * @return 是否成功
   */
  boolean edit(OptLogEditDTO editDTO);

  /**
   * 详情
   *
   * @param id 主键
   * @return 实例对象
   */
  OptLogDetailVO detail(Integer id);

  /**
   * 分页查询
   *
   * @param optLog   筛选条件
   * @param pageNo   页数
   * @param pageSize 条数
   * @return {@link Page}
   */
  Page<OptLog> page(OptLog optLog, Integer pageNo, Integer pageSize);

  /**
   * 删除
   *
   * @param id 主键
   * @return 是否成功
   */
  boolean deleteById(Integer id);

  /**
   * 判断是否唯一值
   *
   * @param column
   * @param value
   * @return boolean
   */
  boolean isUnique(String column, String value);

  /**
   * 异步保存操作日志
   *
   * @param createDTO 创建-请求体
   */
  void asyncSaveOptLog(OptLogCreateDTO createDTO);

  /**
   * 异步批量保存日志
   *
   * @param optLogCreateBatch 批量创建日志
   */
  void asyncBatchSaveLog(List<OptLogCreateDTO> optLogCreateBatch);

}
java
package com.calvin.spring.boot.example.operation.log.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.calvin.spring.boot.example.operation.log.domain.dto.OptLogCreateDTO;
import com.calvin.spring.boot.example.operation.log.domain.dto.OptLogEditDTO;
import com.calvin.spring.boot.example.operation.log.domain.entity.OptLog;
import com.calvin.spring.boot.example.operation.log.mapper.OptLogMapper;
import com.calvin.spring.boot.example.operation.log.service.IOptLogService;
import com.calvin.spring.boot.example.operation.log.valid.OptLogValid;
import com.calvin.spring.boot.example.operation.log.domain.vo.OptLogDetailVO;
import com.calvin.spring.boot.example.operation.log.build.OptLogBuild;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;

import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 操作日志表(OptLog)-服务实现类
 *
 * @author Calvin
 * @since 2024-05-01 14:51:11
 */
@Service("optLogService")
public class OptLogServiceImpl extends ServiceImpl<OptLogMapper, OptLog> implements IOptLogService {

  @Resource
  private OptLogValid optLogValid;


  @Override
  public boolean create(OptLogCreateDTO createDTO) {
    // 验证: 新增参数
    this.optLogValid.verifyCreateParam(createDTO);
    // 构建: 新增参数
    OptLog optLog = OptLogBuild.createBuild(createDTO);
    // 新增: 保存
    return save(optLog);
  }

  @Override
  public boolean edit(OptLogEditDTO editDTO) {
    OptLog find = this.optLogValid.verifyEditParamReturn(editDTO);
    // 构建: 编辑参数
    OptLog optLog = OptLogBuild.editBuild(editDTO, find);
    // 编辑: 更新
    return updateById(optLog);
  }

  @Override
  public OptLogDetailVO detail(Integer id) {
    OptLog find = getById(id);
    if (find == null) {
      return null;
    }
    // 构建: 详情-响应体
    return OptLogBuild.toDetailBuild(find);
  }

  @Override
  public Page<OptLog> page(OptLog optLog, Integer pageNo, Integer pageSize) {
    Page<OptLog> page = new Page<>(pageNo, pageSize);
    return page(page, Wrappers.lambdaQuery(optLog));
  }

  @Override
  public boolean deleteById(Integer id) {
    this.optLogValid.verifyDeleteParamReturn(id);
    // 删除
    return removeById(id);
  }

  @Override
  public boolean isUnique(String column, String value) {
    return count(new QueryWrapper<OptLog>().eq(column, value)) > 0;
  }

  @Async(value = "taskExecutor")
  @Override
  public void asyncSaveOptLog(OptLogCreateDTO createDTO) {
    this.create(createDTO);
  }

  @Override
  public void asyncBatchSaveLog(List<OptLogCreateDTO> optLogCreateBatch) {
    List<OptLog> saveBatchLogs = optLogCreateBatch.stream().map(OptLogBuild::createBuild).collect(Collectors.toList());
    saveBatch(saveBatchLogs, saveBatchLogs.size());
  }
}
  • 控制层(Controller)
java
package com.calvin.spring.boot.example.operation.log.controller;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.calvin.spring.boot.example.operation.log.domain.dto.OptLogCreateDTO;
import com.calvin.spring.boot.example.operation.log.domain.dto.OptLogEditDTO;
import com.calvin.spring.boot.example.operation.log.domain.entity.OptLog;
import com.calvin.spring.boot.example.operation.log.domain.vo.OptLogDetailVO;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.calvin.spring.boot.example.operation.log.service.IOptLogService;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;

import javax.validation.Valid;
import javax.annotation.Resource;
import java.util.List;


/**
 * 操作日志表(OptLog)-控制层
 *
 * @author Calvin
 * @since 2024-05-01 15:01:28
 */
@RestController
@RequestMapping("optLog")
public class OptLogController {

  /**
   * 服务对象
   */
  @Resource
  private IOptLogService optLogService;

  /**
   * 分页查询
   *
   * @param optLog   筛选条件
   * @param pageNo   页数
   * @param pageSize 条数
   * @return 查询结果
   */
  @GetMapping("/page")
  public ResponseEntity<Page<OptLog>> page(OptLog optLog,
                                           @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
                                           @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize
  ) {
    return ResponseEntity.ok(this.optLogService.page(optLog, pageNo, pageSize));
  }

  /**
   * 列表查询
   *
   * @param optLog 筛选条件
   * @return 查询结果
   */
  @GetMapping("/list")
  public ResponseEntity<List<OptLog>> list(OptLog optLog) {
    return ResponseEntity.ok(optLogService.list(Wrappers.lambdaQuery(optLog)));
  }

  /**
   * 详情
   *
   * @param id 主键
   * @return OptLogDetailVO 详情-响应体
   */
  @GetMapping("/detail")
  public ResponseEntity<OptLogDetailVO> detail(@RequestParam("id") Integer id) {
    return ResponseEntity.ok(this.optLogService.detail(id));
  }

  /**
   * 新增
   *
   * @param createDTO 新增-请求体
   * @return 新增是否成功
   */
  @PostMapping("/create")
  public ResponseEntity<Boolean> create(@RequestBody @Valid OptLogCreateDTO createDTO) {
    return ResponseEntity.ok(this.optLogService.create(createDTO));
  }

  /**
   * 编辑
   *
   * @param editDTO 编辑-请求体
   * @return 编辑是否成功
   */
  @PutMapping("/edit")
  public ResponseEntity<Boolean> edit(@RequestBody @Valid OptLogEditDTO editDTO) {
    return ResponseEntity.ok(this.optLogService.edit(editDTO));
  }

  /**
   * 删除
   *
   * @param id 主键
   * @return 删除是否成功
   */
  @DeleteMapping("/delete")
  public ResponseEntity<Boolean> deleteById(@RequestParam("id") Integer id) {
    return ResponseEntity.ok(this.optLogService.deleteById(id));
  }

}

4. AOP 实现操作日志拦截

  • 创建-@OptLog操作日志注解
java
package com.calvin.spring.boot.example.operation.log.annotation;

import com.calvin.spring.boot.example.operation.log.constants.OptLogModule;

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;


/**
 * 操作日志-注解
 *
 * @author calvin
 * @date 2024/04/25
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OptLog {

  /**
   * 日志内容
   *
   * @return {@link String}
   */
  String content() default "";

  /**
   * 匹配字段
   *
   * @return {@link String}
   */
  String matchField() default "";

  /**
   * 匹配字段值转描述
   *
   * @return {@link String}
   */
  String matchFieldValToDesc() default "";

  /**
   * 操作模块
   *
   * @return {@link String}
   */
  String optModule() default OptLogModule.DEFAULT;

  /**
   * 操作日志类型
   *
   * @return (1查询,2添加,3修改,4删除)
   */
  int operateType() default 0;

  /**
   * 业务ID(唯一标识)
   *
   * @return {@link String}
   */
  String bizId() default "";
}
  • 拦截@OptLog操作日志注解
java
package com.calvin.spring.boot.example.operation.log.aspect;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.text.CharSequenceUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.calvin.spring.boot.example.operation.log.annotation.OptLog;
import com.calvin.spring.boot.example.operation.log.constants.OptTypeConstant;
import com.calvin.spring.boot.example.operation.log.domain.vo.SysUserDetailVO;
import com.calvin.spring.boot.example.operation.log.exception.BizException;
import com.calvin.spring.boot.example.operation.log.service.IOptLogService;
import com.calvin.spring.boot.example.operation.log.service.ISysUserService;
import com.calvin.spring.boot.example.operation.log.uitl.IPUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.calvin.spring.boot.example.operation.log.domain.dto.OptLogCreateDTO;
import lombok.extern.slf4j.Slf4j;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
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.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.HandlerMapping;

import javax.annotation.Resource;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;


/**
 * 操作日志-切面
 *
 * @author calvin
 * @date 2024/04/25
 */
@Aspect
@Component
@Slf4j
public class OptLogAspect {

    @Resource
    private IOptLogService optLogService;
    @Resource
    private ISysUserService sysUserService;

    @Pointcut("@annotation(com.calvin.spring.boot.example.operation.log.annotation.OptLog)")
    public void logPointCut() {

    }

    @Around("logPointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        // 开始执行
        long beginTime = System.currentTimeMillis();
        // 是否执行成功
        boolean success = true;
        // 执行结果
        Object result = null;
        try {
            // 执行方法
            result = point.proceed();
            return result;
        } catch (Exception e) {
            // 出现异常
            success = false;
            result = e;
            throw e;
        } finally {
            // 执行时长(毫秒)
            long time = System.currentTimeMillis() - beginTime;
            // 保存日志(异常捕获处理,防止数据太大存储失败,导致业务失败)JT-238
            try {
                // 保存日志
                saveOptLog(point, time, success, result);
            } catch (Exception e) {
                log.error("【操作日志】写入失败!", e);
            }
        }
    }

    /**
     * 保存操作日志信息
     *
     * @param joinPoint 切入点
     * @param time      执行时长
     * @param success   方法执行是否成功
     * @param obj       响应数据
     */
    private void saveOptLog(ProceedingJoinPoint joinPoint, long time, boolean success, Object obj) throws IOException {
        // 获取执行方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        Method method = signature.getMethod();
        if (requestAttributes == null) {
            return;
        }
        HttpServletRequest request = requestAttributes.getRequest();

        // 获取注解信息
        OptLog optLog = method.getAnnotation(OptLog.class);
        if (optLog == null) {
            return;
        }

        // 批量操作
        if (OptTypeConstant.BATCH == optLog.operateType()) {
            // 匹配内容和业务ID
            if (optLog.content().contains("%s") && CharSequenceUtil.isNotBlank(optLog.matchField()) && StringUtils.isNotBlank(optLog.bizId())) {
                this.batchOptLogRecordContainBizId(request, method, joinPoint, optLog, success, obj, time);
            }
            // 只匹配内容
            else if (optLog.content().contains("%s") && CharSequenceUtil.isNotBlank(optLog.matchField())) {
                this.batchOptLogRecordNullBizId(request, method, joinPoint, optLog, success, obj, time);
            }
            // 只记录单条
            else {
                this.singleOptLogRecord(request, method, joinPoint, optLog, success, obj, time);
            }
        }
        // 其他操作
        else {
            this.singleOptLogRecord(request, method, joinPoint, optLog, success, obj, time);
        }
    }

    /**
     * 批量操作日志记录空业务id
     *
     * @param request   请求
     * @param method    方法
     * @param joinPoint 连接点
     * @param optLog    操作日志
     * @param success   成功
     * @param obj       obj
     * @param time      耗时
     */
    private void batchOptLogRecordNullBizId(HttpServletRequest request, Method method, ProceedingJoinPoint joinPoint, OptLog optLog, boolean success, Object obj, long time) {
        List<String> contents = new ArrayList<>(0);
        // 匹配: 获取请求参数字段值列表
        this.matchReqFieldValues(request, joinPoint, optLog.matchField(), null, contents);
        if (CollUtil.isEmpty(contents)) {
            log.error("【批量日志记录】匹配失败,内容不匹配,批量保存失败!");
            return;
        }

        // 获取当前用户管理
        SysUserDetailVO reqUser = getReqUser(request, method);
        List<OptLogCreateDTO> optLogCreateBatch = contents.stream().map(content -> {
            String value = content == null ? "" : content;
            if (CharSequenceUtil.isNotBlank(optLog.matchFieldValToDesc())) {
                // 匹配:字段值转换成描述
                value = this.matchFieldValueToDesc(optLog.matchFieldValToDesc(), content);
            }
            // 日志实体
            OptLogCreateDTO dto = new OptLogCreateDTO();
            // 设置: 操作内容
            dto.setOptContent(String.format(optLog.content(), value));
            if (reqUser != null) {
                // 操作用户
                dto.setOptUserId(String.valueOf(reqUser.getId()));
                dto.setOptUsername(reqUser.getRealName());
            }
            // 请求的参数
            dto.setOptReq(getReqParams(request, joinPoint));
            // 请求地址
            dto.setOptUrl(request.getRequestURI());
            // 请求IP
            dto.setIp(IPUtils.getIpAddr(request));
            // 创建时间
            dto.setCreateTime(new Date());
            // 执行是否成功
            dto.setLogSuccess(success);
            // 模块
            dto.setOptModule(optLog.optModule());
            // 执行结果
            if (obj != null) {
                dto.setOptRes(parseResponseData(success, obj));
            }
            dto.setTime(time);
            return dto;
        }).collect(Collectors.toList());
        optLogService.asyncBatchSaveLog(optLogCreateBatch);


    }

    /**
     * 匹配请求参数字段值
     *
     * @param req        要求事情
     * @param joinPoint  连接点
     * @param fieldParam 字段参数
     * @param bizId      业务ID
     * @return {@link Map}<{@link String}, {@link String}>
     */
    public void matchReqFieldValues(HttpServletRequest req, ProceedingJoinPoint joinPoint, String fieldParam, String bizId, Object o) {

        try {
            Signature signature = joinPoint.getSignature();
            if (!(signature instanceof MethodSignature)) {
                throw new IllegalArgumentException("This advice only works for method executions");
            }

            Object[] parameterValues = joinPoint.getArgs();
            String httpMethod = req.getMethod();
            if (!isRequestBodyRequired(httpMethod)) {
                throw new IllegalArgumentException("暂不支持其他请求方式!");
            }

            ObjectMapper objectMapper = new ObjectMapper();

            // 使用 JSONNode 处理参数数组
            for (Object obj : parameterValues) {
                JsonNode jsonNode = objectMapper.valueToTree(obj);
                processJsonNode(jsonNode, o, fieldParam, bizId);
            }
        } catch (Exception e) {
            log.error("【日志拦截】匹配请求中的字段值失败,异常信息: {}", e.getMessage());
        }
    }


    /**
     * 处理json节点
     *
     * @param jsonNode   json节点
     * @param o          存储
     * @param fieldParam 字段参数
     * @param bizId      商业标识
     */
    private void processJsonNode(JsonNode jsonNode, Object o, String fieldParam, String bizId) {
        if (jsonNode.isObject()) {
            processJsonObject((ObjectNode) jsonNode, o, fieldParam, bizId);
        } else if (jsonNode.isArray()) {
            processJsonArray((ArrayNode) jsonNode, o, fieldParam, bizId);
        }
    }

    /**
     * 处理json对象
     *
     * @param jsonObject json对象
     * @param o          存储
     * @param fieldParam 字段参数
     * @param bizId      商业标识
     */
    private void processJsonObject(ObjectNode jsonObject, Object o, String fieldParam, String bizId) {
        if (jsonObject.has(fieldParam) && jsonObject.has(bizId)) {
            String content = jsonObject.get(fieldParam).asText();
            JsonNode keys = jsonObject.get(bizId);
            if (keys.isArray()) {
                keys.forEach(key -> {
                    if (o instanceof Map) {
                        ((Map<String, String>) o).put(key.asText(), content);
                    }

                });
            } else {
                ((Map<String, String>) o).put(keys.asText(), content);
            }
        } else if (jsonObject.has(fieldParam)) {
            JsonNode contents = jsonObject.get(fieldParam);
            if (contents.isArray()) {
                contents.forEach(content -> {
                    if (o instanceof List) {
                        ((List<String>) o).add(content.toString());
                    }

                });
            }
        }
    }

    /**
     * 处理json数组
     *
     * @param jsonArray  json数组
     * @param o          存储
     * @param fieldParam 场参数
     * @param bizId      商业标识
     */
    private void processJsonArray(ArrayNode jsonArray, Object o, String fieldParam, String bizId) {
        jsonArray.forEach(jsonNode -> processJsonNode(jsonNode, o, fieldParam, bizId));
    }

    /**
     * 批量操作日志记录中包含业务id
     *
     * @param request   请求
     * @param method    方法
     * @param joinPoint 连接点
     * @param optLog    操作日志
     * @param success   成功
     * @param obj       obj
     * @param time      耗时
     */
    private void batchOptLogRecordContainBizId(HttpServletRequest request, Method method, ProceedingJoinPoint joinPoint, OptLog optLog, boolean success, Object obj, long time) {
        Map<String, String> bizIdToContentMap = new HashMap<>(0);
        // 匹配: 获取请求参数字段值列表
        this.matchReqFieldValues(request, joinPoint, optLog.matchField(), optLog.bizId(), bizIdToContentMap);

        if (MapUtil.isEmpty(bizIdToContentMap)) {
            log.error("【批量日志记录】匹配失败,业务ID 不匹配,批量保存失败!");
            return;
        }

        // 获取当前用户管理
        SysUserDetailVO reqUser = getReqUser(request, method);
        List<OptLogCreateDTO> optLogCreateBatch = bizIdToContentMap.entrySet().stream().map(p -> {
            String value = p.getValue() == null ? "" : p.getValue();
            if (CharSequenceUtil.isNotBlank(optLog.matchFieldValToDesc())) {
                // 匹配:字段值转换成描述
                value = this.matchFieldValueToDesc(optLog.matchFieldValToDesc(), p.getValue());
            }
            // 日志实体
            OptLogCreateDTO dto = new OptLogCreateDTO();
            dto.setBizId(p.getKey());
            // 设置: 操作内容
            dto.setOptContent(String.format(optLog.content(), value));
            if (reqUser != null) {
                // 操作用户
                dto.setOptUserId(String.valueOf(reqUser.getId()));
                dto.setOptUsername(reqUser.getRealName());
            }
            // 请求的参数
            dto.setOptReq(getReqParams(request, joinPoint));
            // 请求地址
            dto.setOptUrl(request.getRequestURI());
            // 请求IP
            dto.setIp(IPUtils.getIpAddr(request));
            // 创建时间
            dto.setCreateTime(new Date());
            // 执行是否成功
            dto.setLogSuccess(success);
            // 模块
            dto.setOptModule(optLog.optModule());
            // 执行结果
            if (obj != null) {
                dto.setOptRes(parseResponseData(success, obj));
            }
            dto.setTime(time);
            return dto;
        }).collect(Collectors.toList());
        optLogService.asyncBatchSaveLog(optLogCreateBatch);
    }

    private void singleOptLogRecord(HttpServletRequest request, Method method, ProceedingJoinPoint joinPoint, OptLog optLog, boolean success, Object obj, long time) {
        // 日志实体
        OptLogCreateDTO dto = new OptLogCreateDTO();
        String content = optLog.content();
        String fieldParam = optLog.matchField();

        if (content.contains("%s") && CharSequenceUtil.isNotBlank(fieldParam)) {
            // 匹配: 获取请求参数字段值
            String value = this.matchReqFieldValue(request, joinPoint, fieldParam);
            if (CharSequenceUtil.isNotBlank(optLog.matchFieldValToDesc())) {
                // 匹配:字段值转换成描述
                value = this.matchFieldValueToDesc(optLog.matchFieldValToDesc(), value);
            }
            content = String.format(content, value);
        }

        if (StringUtils.isNotBlank(optLog.bizId())) {
            String bizId = this.matchReqFieldValue(request, joinPoint, optLog.bizId());
            dto.setBizId(StringUtils.isNotBlank(bizId) ? bizId : null);
        }

        // 设置: 操作内容
        dto.setOptContent(content);

        // 获取当前用户管理
        SysUserDetailVO reqUser = getReqUser(request, method);
        if (reqUser != null) {
            // 操作用户
            dto.setOptUserId(String.valueOf(reqUser.getId()));
            dto.setOptUsername(reqUser.getRealName());
        }

        // 请求的参数
        dto.setOptReq(getReqParams(request, joinPoint));
        // 请求地址
        dto.setOptUrl(request.getRequestURI());
        // 请求IP
        dto.setIp(IPUtils.getIpAddr(request));
        // 创建耗时
        dto.setCreateTime(new Date());
        // 执行是否成功
        dto.setLogSuccess(success);
        // 模块
        dto.setOptModule(optLog.optModule());
        // 执行结果
        if (obj != null) {
            dto.setOptRes(parseResponseData(success, obj));
        }
        dto.setTime(time);

        // 异步保存
        optLogService.asyncSaveOptLog(dto);

    }

    /**
     * 匹配-字段值转换成描述
     *
     * @param matchFieldValToDesc 匹配-字段值转换成描述
     * @param key                 关键
     * @return {@link String}
     */
    private String matchFieldValueToDesc(String matchFieldValToDesc, String key) {
        try {
            JSONObject jsonObject = JSONObject.parseObject(matchFieldValToDesc);
            return jsonObject.getString(key);
        } catch (Exception e) {
            log.error("【日志保存】匹配字段值对应描述异常 : {}", e.getMessage());
        }
        return "";
    }


    /**
     * 匹配请求中的字段值
     *
     * @param req        请求体
     * @param joinPoint  切点
     * @param fieldParam 字段参数名
     * @return 匹配到的字段值或提示信息
     */
    public String matchReqFieldValue(HttpServletRequest req, ProceedingJoinPoint joinPoint, String fieldParam) {
        try {
            // 获取方法签名
            Signature signature = joinPoint.getSignature();
            if (!(signature instanceof MethodSignature)) {
                throw new IllegalArgumentException("This advice only works for method executions");
            }

            Object[] parameterValues = joinPoint.getArgs();
            String httpMethod = req.getMethod();
            if (isRequestBodyRequired(httpMethod)) {
                return matchRequestBodyFieldValue(parameterValues, fieldParam);
            } else {
                return matchPathVariableFieldValue(req, joinPoint, fieldParam);
            }
        } catch (Exception e) {
            log.error("【日志拦截】匹配请求中的字段值失败,异常信息: {}", e.getMessage());
        }
        return "";
    }

    /**
     * 检查是否需要请求体
     *
     * @param httpMethod HTTP请求方法
     * @return 是否需要请求体
     */
    private boolean isRequestBodyRequired(String httpMethod) {
        return HttpMethod.POST.name().equals(httpMethod)
                || HttpMethod.PUT.name().equals(httpMethod)
                || HttpMethod.PATCH.name().equals(httpMethod)
                || HttpMethod.DELETE.name().equals(httpMethod);
    }

    /**
     * 匹配请求体中的字段值
     *
     * @param parameterValues 请求参数值数组
     * @param fieldParam      字段参数名
     * @return 匹配到的字段值或提示信息
     */
    private String matchRequestBodyFieldValue(Object[] parameterValues, String fieldParam) {
        Optional<String> matchedValue = Arrays.stream(parameterValues)
                .map(parameterValue -> JSON.parseObject(JSON.toJSONString(parameterValue)))
                .map(jsonObject -> jsonObject.getString(fieldParam))
                .filter(Objects::nonNull)
                .findFirst();
        return matchedValue.orElse("No parameter found with name: " + fieldParam);
    }

    /**
     * 匹配路径变量中的字段值
     *
     * @param req        请求体
     * @param joinPoint  切点
     * @param fieldParam 字段参数名
     * @return 匹配到的字段值或提示信息
     */
    private String matchPathVariableFieldValue(HttpServletRequest req, ProceedingJoinPoint joinPoint, String fieldParam) {
        HandlerMethod handlerMethod = (HandlerMethod) req.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE);
        if (handlerMethod != null) {
            Method method = handlerMethod.getMethod();
            Object[] args = joinPoint.getArgs();
            String[] paramNames = new LocalVariableTableParameterNameDiscoverer().getParameterNames(method);
            Map<String, String> paramMap = IntStream.range(0, args.length)
                    // 过滤掉空参数
                    .filter(i -> args[i] != null)
                    .boxed()
                    .collect(Collectors.toMap(
                            // 使用索引访问paramNames数组
                            i -> paramNames[i],
                            i -> args[i].toString()
                    ));
            String value = paramMap.get(fieldParam);
            return value != null ? value : "No parameter found with name: " + fieldParam;
        }
        return "";
    }

    /**
     * 获取请求参数
     *
     * @param req       请求
     * @param joinPoint 连接点
     * @return JSON格式的请求参数
     */
    private String getReqParams(HttpServletRequest req, JoinPoint joinPoint) {
        String httpMethod = req.getMethod();
        String params = "";
        Gson gson = new Gson();

        // 处理 POST、PUT、PATCH 请求
        if (HttpMethod.POST.name().equals(httpMethod) || HttpMethod.PUT.name().equals(httpMethod) || HttpMethod.PATCH.name().equals(httpMethod)) {
            Object[] paramsArray = joinPoint.getArgs();
            JsonArray filteredArguments = new JsonArray();

            // 过滤请求参数
            for (Object param : paramsArray) {
                if (!(param instanceof ServletRequest) && !(param instanceof ServletResponse) && !(param instanceof MultipartFile)) {
                    if (param != null && param.toString().length() <= 500) {
                        filteredArguments.add(gson.toJsonTree(param));
                    }
                }
            }

            // 转换为JSON字符串
            params = gson.toJson(filteredArguments);
        }
        // 处理其他请求类型
        else {
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            Object[] args = joinPoint.getArgs();
            LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
            String[] paramNames = u.getParameterNames(method);

            if (args != null && paramNames != null) {
                JsonObject jsonObject = new JsonObject();
                // 构建参数名和参数值的键值对
                for (int i = 0; i < args.length; i++) {
                    if (args[i] != null) {
                        jsonObject.addProperty(paramNames[i], args[i].toString());
                    }
                }
                // 转换为JSON字符串
                params = gson.toJson(jsonObject);
            }
        }
        return params;
    }


    /**
     * 解析响应数据
     *
     * @param obj     响应结果
     * @param success 成功
     * @return {@link String}
     */
    private static String parseResponseData(boolean success, Object obj) {
        String responseData;
        if (success) {
            responseData = JSON.toJSONString(obj);
        } else {
            if (obj instanceof BizException) {
                BizException e = (BizException) obj;
                responseData = e.getCode() + ":" + e.getMessage();
            } else {
                Exception e = (Exception) obj;
                responseData = e.getClass().getName() + "。" + e.getMessage();
            }
        }
        // 长度控制
        if (StringUtils.isNotBlank(responseData) && responseData.length() > 1500) {
            responseData = responseData.substring(0, 1500);
        }
        log.info("响应数据:{}", obj);
        return responseData;
    }


    /**
     * 获取请求用户
     *
     * @param req    请求
     * @param method 方法
     * @return {@link SysUserDetailVO}
     */
    private SysUserDetailVO getReqUser(HttpServletRequest req, Method method) {
        // String token = req.getHeader("token");
        // String json =  redisClient.getVal(String.format("%s", token));
        // TODO 当前模拟当前用户
        return sysUserService.detail(1L);
    }

}

5. 记录接口操作日志

java
package com.calvin.spring.boot.example.operation.log.controller;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.calvin.spring.boot.example.operation.log.annotation.OptLog;
import com.calvin.spring.boot.example.operation.log.constants.OptLogModule;
import com.calvin.spring.boot.example.operation.log.constants.OptTypeConstant;
import com.calvin.spring.boot.example.operation.log.domain.dto.SysUserBatchStatusDTO;
import com.calvin.spring.boot.example.operation.log.domain.dto.SysUserCreateDTO;
import com.calvin.spring.boot.example.operation.log.domain.dto.SysUserEditDTO;
import com.calvin.spring.boot.example.operation.log.domain.entity.SysUser;
import com.calvin.spring.boot.example.operation.log.domain.vo.SysUserDetailVO;
import com.calvin.spring.boot.example.operation.log.service.ISysUserService;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;

import javax.validation.Valid;
import javax.annotation.Resource;
import java.util.List;


/**
 * 系统用户表(SysUser)-控制层
 *
 * @author Calvin
 * @since 2024-05-01 15:03:18
 */
@RestController
@RequestMapping("/sys/user")
public class SysUserController {

  /**
   * 服务对象
   */
  @Resource
  private ISysUserService sysUserService;

  /**
   * 分页查询
   *
   * @param sysUser  筛选条件
   * @param pageNo   页数
   * @param pageSize 条数
   * @return 查询结果
   */
  @GetMapping("/page")
  @OptLog(optModule = OptLogModule.SYS_USER_MANAGE, content = "【分页查询】")
  public ResponseEntity<Page<SysUser>> page(SysUser sysUser,
                                            @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
                                            @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize
  ) {
    return ResponseEntity.ok(this.sysUserService.page(sysUser, pageNo, pageSize));
  }

  /**
   * 列表查询
   *
   * @param sysUser 筛选条件
   * @return 查询结果
   */
  @OptLog(optModule = OptLogModule.SYS_USER_MANAGE, content = "【列表查询】")
  @GetMapping("/list")
  public ResponseEntity<List<SysUser>> list(SysUser sysUser) {
    return ResponseEntity.ok(sysUserService.list(Wrappers.lambdaQuery(sysUser)));
  }

  /**
   * 详情
   *
   * @param id 主键
   * @return SysUserDetailVO 详情-响应体
   */
  @GetMapping("/detail")
  @OptLog(optModule = OptLogModule.SYS_USER_MANAGE, content = "【详情】id:%s", matchField = "id")
  public ResponseEntity<SysUserDetailVO> detail(@RequestParam("id") Long id) {
    return ResponseEntity.ok(this.sysUserService.detail(id));
  }

  /**
   * 新增
   *
   * @param createDTO 新增-请求体
   * @return 新增是否成功
   */
  @OptLog(optModule = OptLogModule.SYS_USER_MANAGE, content = "【新增用户】账号:%s", matchField = "account")
  @PostMapping("/create")
  public ResponseEntity<Boolean> create(@RequestBody @Valid SysUserCreateDTO createDTO) {
    return ResponseEntity.ok(this.sysUserService.create(createDTO));
  }

  /**
   * 编辑
   *
   * @param editDTO 编辑-请求体
   * @return 编辑是否成功
   */
  @OptLog(optModule = OptLogModule.SYS_USER_MANAGE, content = "【编辑用户】id: %s", matchField = "id")
  @PutMapping("/edit")
  public ResponseEntity<Boolean> edit(@RequestBody @Valid SysUserEditDTO editDTO) {
    return ResponseEntity.ok(this.sysUserService.edit(editDTO));
  }

  /**
   * 删除
   *
   * @param id 主键
   * @return 删除是否成功
   */
  @DeleteMapping("/delete")
  @OptLog(optModule = OptLogModule.SYS_USER_MANAGE, content = "【删除用户】id: %s", matchField = "id")
  public ResponseEntity<Boolean> deleteById(@RequestParam("id") Long id) {
    return ResponseEntity.ok(this.sysUserService.deleteById(id));
  }

  /**
   * 批处理状态
   *
   * @param sysUserBatchStatusDTO 系统用户批处理状态-请求体
   * @return {@link ResponseEntity}<{@link Boolean}>
   */
  @OptLog(optModule = OptLogModule.SYS_USER_MANAGE, operateType = OptTypeConstant.BATCH, content = "【批量%s】", matchField = "status", matchFieldValToDesc = "{\"false\": \"禁用\", \"true\": \"启用\"}",  bizId = "ids")
  @PostMapping("/batch/status")
  public ResponseEntity<Boolean> batchStatus(@RequestBody @Valid SysUserBatchStatusDTO sysUserBatchStatusDTO) {
    return ResponseEntity.ok(this.sysUserService.batchStatus(sysUserBatchStatusDTO));
  }


}

五、展示存储结构