一、背景
由于
业务体系不断迭代和功能的升级
,开发人员往往需要对业务开发功能不断升级改造
,同时产生了各种各样的问题
,例如:
- 在
生产环境
出现了业务功能异常问题
,无法复现用户是如何操作对应的功能产生的问题情况。- 在
微服务
中服务与服务之间的调用,链路产生的异常问题,导致开发人员排查问题过程异常艰难。- 在
上下游系统调用
中,没有明确的日志记录,也会导致排查过程异常艰难
或无从下手
。 所以,接口操作日志功能
就体现了尤为重要表现。
二、作用
- 开发人员: 主要帮组开发人员
快速定位业务功能异常问题
, 同时记录用户操作行为
有利于后期做大数据分析用户行为。 - 运营人员: 主要帮组运营人员可以更直观看出
操作功能被那些用户进行操作
, 可以防止重复操作功能,同时减少不必要的争议。
三、使用范围
操作日志
使用范围主要作用于:
新增
删除
修改
状态的更新
- 查询(使用的很少,通常不建议使用在查询,除了比较重要的查询功能, 例如:
第三方支付回调
)
四、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));
}
}