在软件开发中,针对用户操作日志的记录是一个普遍且重要的需求,旨在跟踪用户行为、保障系统安全及进行后续的问题追踪与性能分析。
传统上,若在每次方法调用时都手动构造日志实体并同步插入数据库,不仅会导致代码冗余且难以维护,还会因同步操作而拖慢系统性能。
为了优化这一流程, 可以使用自定义注解和aop的方式进行对日志的记录, 能动态记录指定记录的参数, 如请求参数, 响应参数, 操作时间, 操作用户的用户名, 方法耗时等等, 可以灵活拓展,而无需在每个业务逻辑点中显式编写日志代码。
在controller
上的方法上添加注解即可实现日志记录
改AOP执行流程图如下, 具体实现如下5步骤
文章目录
1. 定义注解和业务类操作类型
BusinessType
业务操作类型(可指定具体模块下具体操作)OperatorType
操作人类别(user/vip/admin)
Log
注解, 存放路径: com.tiantian.common.log.annotation
import java.lang.annotation.*;
/**
* 自定义操作日志记录注解
*
* @author ruoyi
*/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/**
* 业务操作类型
*/
BusinessType businessType() default BusinessType.OTHER;
/**
* 操作人类别
*/
OperatorType operatorType() default OperatorType.NEWCOMER;
/**
* 是否保存请求的参数
*/
boolean isSaveRequestData() default true;
/**
* 是否保存响应的参数
*/
boolean isSaveResponseData() default true;
/**
* 排除指定的请求参数
*/
String[] excludeParamNames() default {};
}
BusinessType
业务操作类型(可自行拓展), 其中number便于条件查询, description用于前端下拉框选项展示, 存放路径: com.tiantian.common.log.enums
/**
* 业务操作类型
*
* @author ruoyi
*/
public enum BusinessType {
CAPTCHA(0,"获取验证码"),
REGISTER_BATCH(1,"批量注册"),
REGISTER(2,"用户注册"),
SET_TOPIC(3,"设置题目"),
UPDATE_TOPIC(4,"更新题目信息"),
DEL_TOPIC_LOGIC(5,"逻辑删除题目"),
DEL_TOPIC_permanent(6,"永久删除题目"),
RECOVER_TOPIC(7,"恢复所删题目"),
NEWCOMER_SUBMIT_TOPIC(8,"候选人提交解题"),
NEWCOMER_UPDATE_ANS(9,"候选人修改答案"),
UPDATE_NEWCOMER(10,"管理员更新候选人信息"),
JUDGE_SETTING(11,"判题人设置"),
JUDGER_DELETE(12,"删除判题人"),
JUDGE_TOPIC(13,"题目评分"),
ENROLL(14,"一键录取候选人"),
NOT_ENROLL(15,"取消录取资格"),
SCORE_LINE(16,"分数线设置"),
ANS_TIME(17,"答题时间设置"),
REGISTER_SETTING(18,"修改注册开关"),
EMAIL(19,"系统邮件发送"),
FILE_EXPORT(20,"文件导出"),
UPDATE_PROFILE(21,"更新个人信息"),
UPDATE_PWD(22,"更新密码"),
ALLOT_NEWCOMER(23,"分配新人"),
CALC_SCORE(24,"计算零分用户"),
DEL_ADMIN_REPLYS(25,"删除工作人员作答记录"),
SET_ENR_STATUS(26,"设置录取状态"),
DEL_LOG(27,"删除操作日志"),
CLEAR_LOG(28,"清空操作日志"),
FILE_UPLOAD(29,"文件上传"),
DEL_FILE(30,"删除文件"),
EXP_LOG(31, "导出日志文件"),
AI_JUDGE(32, "AI判题"),
OTHER(33,"其它");
final int number;
final String description;
BusinessType(int number, String description) {
this.number = number;
this.description = description;
}
public int getNumber() {
return number;
}
public String getDescription() {
return description;
}
}
OperatorType
操作人类别, 便于快速区别操作角色, 存放路径: com.tiantian.common.log.enums
/**
* 操作人类别
*
* @author ruoyi
*/
public enum OperatorType {
/**
* 其它
*/
OTHER,
/**
* 管理员
*/
ADMIN,
/**
* 新人
*/
NEWCOMER
}
2. 定义日志实体
SysOperLog
存放目录 com.tiantian.common.log.domain
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 操作日志记录表 oper_log
*/
@Data
public class SysOperLog implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 日志主键
*/
private Long operId;
/**
* 操作模块
*/
private String title;
/**
* 业务类型
*/
private Integer businessType;
/**
* 业务类型数组
*/
private Integer[] businessTypes;
/**
* 请求方法
*/
private String method;
/**
* 请求方式
*/
private String requestMethod;
/**
* 操作类别
*/
private Integer operatorType;
/**
* 操作人员
*/
private String operName;
/**
* 请求url
*/
private String operUrl;
/**
* 操作地址
*/
private String operIp;
/**
* 请求参数
*/
private String operParam;
/**
* 返回参数
*/
private String jsonResult;
/**
* 操作状态(0正常 1异常)
*/
private Integer status;
/**
* 错误消息
*/
private String errorMsg;
/**
* 操作时间
*/
private LocalDateTime operTime;
/**
* 消耗的时间
*/
private Long costTime;
}
3. 创建数据库表并定义Mapper和Xml
3.1 创建日志表
DROP TABLE IF EXISTS `sys_oper_log`;
CREATE TABLE `sys_oper_log` (
`oper_id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志主键',
`title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '模块标题',
`business_type` int NULL DEFAULT 0 COMMENT '业务类型(0其它 1新增 2修改 3删除 4登录 5登出 6录取 7未录取)',
`method` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '方法名称',
`request_method` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '请求方式',
`operator_type` int NULL DEFAULT 0 COMMENT '操作类别(0提交题目 1录取 2判题)',
`oper_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '操作人员的昵称',
`dept_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '部门类型 (0新人 0技术部 1统筹部)',
`oper_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '请求URL',
`oper_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '主机地址',
`oper_param` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '请求参数',
`json_result` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '返回参数',
`status` int NULL DEFAULT 0 COMMENT '操作状态(0正常 1异常)',
`error_msg` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '错误消息',
`oper_time` datetime NULL DEFAULT NULL COMMENT '操作时间',
`cost_time` bigint NULL DEFAULT 0 COMMENT '消耗时间',
PRIMARY KEY (`oper_id`) USING BTREE,
INDEX `idx_sys_oper_log_bt`(`business_type` ASC) USING BTREE,
INDEX `idx_sys_oper_log_s`(`status` ASC) USING BTREE,
INDEX `idx_sys_oper_log_ot`(`oper_time` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 530 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '操作日志记录' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
3.2 定义SysOperLogMapper
SysOperLogMapper
com.tiantian.common.log.mapper
import com.tiantian.common.log.domain.SysOperLog;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 操作日志 数据层
*/
@Mapper
public interface SysOperLogMapper {
/**
* 插入日志数据
*/
void insertOperLog(SysOperLog operLog);
/**
* 查询日志列表
*/
List<SysOperLog> selectOperLogList(SysOperLog sysOperLog);
/**
* 批量删除系统操作日志
*
* @param operIds 需要删除的操作日志ID
* @return 结果
*/
int deleteOperLogByIds(Long[] operIds);
/**
* 根据operId查询日志数据
* @param operId 日志主键
*/
SysOperLog selectOperLogById(Long operId);
/**
* 清空操作日志
*/
void cleanOperLog();
}
3.3 定义MapperXML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tiantian.system.mapper.SysOperLogMapper">
<!-- 结果集 -->
<resultMap type="com.tiantian.system.domain.entity.SysOperLog" id="SysOperLogMap">
<result property="operId" column="oper_id" jdbcType="INTEGER"/>
<result property="title" column="title" jdbcType="VARCHAR"/>
<result property="businessType" column="business_type" jdbcType="INTEGER"/>
<result property="method" column="method" jdbcType="VARCHAR"/>
<result property="requestMethod" column="request_method" jdbcType="VARCHAR"/>
<result property="operatorType" column="operator_type" jdbcType="INTEGER"/>
<result property="operName" column="oper_name" jdbcType="VARCHAR"/>
<result property="operUrl" column="oper_url" jdbcType="VARCHAR"/>
<result property="operIp" column="oper_ip" jdbcType="VARCHAR"/>
<result property="operParam" column="oper_param" jdbcType="VARCHAR"/>
<result property="jsonResult" column="json_result" jdbcType="VARCHAR"/>
<result property="status" column="status" jdbcType="INTEGER"/>
<result property="errorMsg" column="error_msg" jdbcType="VARCHAR"/>
<result property="operTime" column="oper_time" jdbcType="TIMESTAMP"/>
<result property="costTime" column="cost_time" jdbcType="INTEGER"/>
</resultMap>
<!-- 基本字段 -->
<sql id="Base_Column_List">
oper_id, title, business_type, method, request_method, operator_type, oper_name, oper_url, oper_ip, oper_param, json_result, status, error_msg, oper_time,cost_time
</sql>
<!-- 基本查询条件 -->
<sql id="Query_Items">
<if test="operId != null">
and oper_id = #{operId}
</if>
<if test="title != null and title != ''">
and title like concat('%', #{title}, '%')
</if>
<if test="businessType != null">
and business_type = #{businessType}
</if>
<if test="method != null and method != ''">
and method like concat('%', #{method}, '%')
</if>
<if test="requestMethod != null and requestMethod != ''">
and request_method = #{requestMethod}
</if>
<if test="operatorType != null">
and operator_type = #{operatorType}
</if>
<if test="operName != null and operName != ''">
and oper_name like concat('%', #{operName}, '%')
</if>
<if test="operUrl != null and operUrl != ''">
and oper_url = #{operUrl}
</if>
<if test="operIp != null and operIp != ''">
and oper_ip like concat('%', #{operIp}, '%')
</if>
<if test="operParam != null and operParam != ''">
and oper_param = #{operParam}
</if>
<if test="jsonResult != null and jsonResult != ''">
and json_result = #{jsonResult}
</if>
<if test="status != null">
and status = #{status}
</if>
<if test="errorMsg != null and errorMsg != ''">
and error_msg = #{errorMsg}
</if>
<if test="operTime != null">
and oper_time = #{operTime}
</if>
</sql>
<!-- 查询单个-->
<select id="selectOperLogById" resultMap="SysOperLogMap">
select
<include refid="Base_Column_List" />
from sys_oper_log
where oper_id = #{operId}
</select>
<!-- 通过实体作为筛选条件查询 -->
<select id="selectOperLogList" resultMap="SysOperLogMap">
select
<include refid="Base_Column_List" />
from sys_oper_log
<where>
<include refid="Query_Items" />
</where>
order by oper_time desc
</select>
<!-- 新增所有列 -->
<insert id="insertOperLog" keyProperty="operId" useGeneratedKeys="true">
insert into sys_oper_log(oper_id, title, business_type, method, request_method, operator_type, oper_name,
oper_url, oper_ip, oper_param, json_result, status, error_msg, oper_time, cost_time)
values (#{operId}, #{title}, #{businessType}, #{method}, #{requestMethod}, #{operatorType}, #{operName},
#{operUrl}, #{operIp}, #{operParam}, #{jsonResult}, #{status}, #{errorMsg},
#{operTime}, #{costTime})
</insert>
<!-- 通过主键删除 -->
<delete id="deleteById">
delete from sys_oper_log where oper_id = #{operId}
</delete>
<!-- 根据ID批量删除 -->
<delete id="deleteOperLogByIds" parameterType="Long">
delete from sys_oper_log where oper_id in
<foreach collection="array" item="operId" open="(" separator="," close=")">
#{operId}
</foreach>
</delete>
<!-- 清空表 -->
<update id="cleanOperLog">
truncate table sys_oper_log
</update>
</mapper>
4. 核心定义切面类
其中用到了三个注解
@Before
处理请求前执行,未到达controller@AfterReturning
处理完请求后执行@AfterThrowing
目标方法执行报错时执行, 但不会与@AfterReturning一起执行
其中Spring AOP
所有注解的执行顺序如下
try{
try{
//@Around 开始
//@Before
method.invoke(..); // 目标方法执行
//@Around 结束
}finally{
//@After
}
//@AfterReturning
}catch(){
//@AfterThrowing
}
日志保存流程图如下
需要引入到的工具类 hutool
LogAspect
存放目录: com.tiantian.common.log.aspect
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.http.HttpMethod;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
/**
* 操作日志记录处理
*
* @author TianTian
*/
@Aspect
@AutoConfiguration
@RequiredArgsConstructor
public class LogAspect2 {
private final SysOperLogMapper sysOperLogMapper;
/**
* 排除敏感属性字段
*/
public static final String[] EXCLUDE_PROPERTIES = {"password", "oldPassword", "newPassword", "confirmPassword"};
/**
* 计算操作消耗时间
*/
private static final ThreadLocal<Long> TIME_THREADLOCAL = new ThreadLocal<>();
/**
* 处理请求前执行
*/
@Before(value = "@annotation(controllerLog)")
public void boBefore(JoinPoint joinPoint, Log controllerLog) {
TIME_THREADLOCAL.set(System.currentTimeMillis());
}
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
handleLog(joinPoint, controllerLog, null, jsonResult);
}
/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
handleLog(joinPoint, controllerLog, e, null);
}
protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
try {
// *========数据库日志=========*//
SysOperLog operLog = new SysOperLog();
// 请求的地址
operLog.setOperIp("可使用获取IP的工具");
// 访问的URI
HttpServletRequest request = getRequest();
operLog.setOperUrl(request.getRequestURI());
// LoginUser loginUser = LoginHelper.getLoginUser();
// operLog.setOperName(loginUser.getNickName());
operLog.setOperName("可以从userService中获取到登录用户名称");
if (e != null) {
// 设置状态为失败 0
operLog.setStatus(0);
operLog.setErrorMsg(e.getMessage());
} else {
// 设置状态为成功 1
operLog.setStatus(1);
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
operLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
operLog.setRequestMethod(request.getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
// 设置消耗时间(单位:毫秒)
Long takeTime = System.currentTimeMillis() - TIME_THREADLOCAL.get();
operLog.setCostTime(takeTime);
// 保存到数据库
sysOperLogMapper.insertOperLog(operLog);
} catch (Exception exp) {
// 记录本地异常日志
exp.printStackTrace();
} finally {
TIME_THREADLOCAL.remove();
}
}
private static HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes.getRequest();
}
/**
* 获取注解中对方法的描述信息 用于Controller层注解
*
* @param log 日志
* @param operLog 操作日志
* @throws Exception
*/
public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception {
// 设置action动作
operLog.setBusinessType(log.businessType().getNumber());
// 设置标题
operLog.setTitle(log.businessType().getDescription());
// 设置操作人类别
operLog.setOperatorType(log.operatorType().ordinal());
// 是否需要保存request,参数和值
if (log.isSaveRequestData()) {
// 获取参数的信息,传入到数据库中。
setRequestValue(joinPoint, operLog, log.excludeParamNames());
}
// 是否需要保存response,参数和值
if (log.isSaveResponseData() && ObjectUtil.isNotNull(jsonResult)) {
operLog.setJsonResult(jsonResult.toString());
}
}
/**
* 获取请求的参数,放到log中
*
* @param operLog 操作日志
*/
private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog, String[] excludeParamNames) {
HttpServletRequest request = getRequest();
Map<String, String> paramsMap = new HashMap<>();
for (Map.Entry<String, String[]> entry : getParams(request).entrySet()) {
paramsMap.put(entry.getKey(), StrUtil.join(",", entry.getValue()));
}
String requestMethod = operLog.getRequestMethod();
if (MapUtil.isEmpty(paramsMap)
&& HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames);
operLog.setOperParam(params);
} else {
MapUtil.removeAny(paramsMap, EXCLUDE_PROPERTIES);
MapUtil.removeAny(paramsMap, excludeParamNames);
operLog.setOperParam(paramsMap.toString());
}
}
/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) {
StringJoiner params = new StringJoiner(" ");
if (ArrayUtil.isEmpty(paramsArray)) {
return params.toString();
}
for (Object o : paramsArray) {
if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
String str = JSONUtil.toJsonStr(o);
JSONObject dict = JSONUtil.parseObj(str);
if (MapUtil.isNotEmpty(dict)) {
MapUtil.removeAny(dict, EXCLUDE_PROPERTIES);
MapUtil.removeAny(dict, excludeParamNames);
str = JSONUtil.toJsonStr(dict);
}
params.add(str);
}
}
return params.toString();
}
/**
* 获得所有请求参数
*
* @param request 请求对象{@link ServletRequest}
* @return Map
*/
public static Map<String, String[]> getParams(ServletRequest request) {
final Map<String, String[]> map = request.getParameterMap();
return Collections.unmodifiableMap(map);
}
/**
* 判断是否需要过滤的对象。
*
* @param o 对象信息。
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
@SuppressWarnings("rawtypes")
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.values()) {
return value instanceof MultipartFile;
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
}
5. 使用注解
在controller层中的方法上添加即可
在数据库中查看记录
当然也可以使用现成的框架, 功能更丰富: mouzt/mzt-biz-log: 支持Springboot,基于注解的可使用变量、可以自定义函数的通用操作日志组件 (github.com)