美文网首页项目经验
记录用户细粒度操作日志

记录用户细粒度操作日志

作者: 修行者12138 | 来源:发表于2020-12-18 12:12 被阅读0次

项目中,常需要记录用户的操作日志,常见的记录日志方案,有以下几种:

方案1

1.定义一个自定义注解@OperateLog,注解的desc属性表示接口功能描述;
2.在需要记录日志的controller接口上,添加@OperateLog注解,并设置desc属性;
3.写一个AOP,把当前用户+接口功能描述记录到数据库;
示例代码

@OperateLog(desc = "下载财务影像资料")
public void downloadFinancialImage(String[] imageNumber, HttpServletResponse response){}
缺点

只能粗粒度地记录用户调用了哪个接口,不能细粒度的记录用户调用该接口具体做了什么事情。比如,该接口的功能描述为“下载财务影像资料”,那么方案1只能记录xx用户调用了“下载财务影像资料”接口,无法记录用户具体下载了哪些资料。

方案2

提供一个记录日志的SDK,在业务代码中使用。

缺点

代码耦合性太强,而且增加了开发人员的负担。

方案3

从接口入参提取关键字,通过关键字定位到用户的细粒度操作,同一个接口,功能是一样的,但是调用同一个接口可以做不同的事情,区别就在于入参,具体步骤如下:
1.定义一个自定义注解@OperateLog,value属性表示接口操作描述,该属性使用Sping El表达式(以下简称SpEl)拼接功能描述+关键字;
示例代码:

@OperateLog(value = "'下载财务影像资料,影像编码包括:' + #imageNumber")
public void downloadFinancialImage(String[] imageNumber, HttpServletResponse response) {}

其中,value = "'下载财务影像资料,影像编码包括:' + #imageNumber"计算后大概长这样:下载财务影像资料,影像编码包括:[1111, 2222]
2.除了记录操作描述,还需要记录日志类型(即属于哪一个功能模块),因此@OperateLog还需要一个type属性;
3.写一个AOP,使用Spring提供的API计算SpEl,把用户信息和日志入库。

SpEl的功能非常强大,多看看几个示例:

支持纯文字

@OperateLog("'查询财务机构维护列表'")
public WebResponse listFinanceBranch(@RequestBody WebRequest<QueryCriteriaDTO> query) 

从复杂对象提取关键字

@OperateLog(value = "'新增风险:'+ #dataSourceDTO.body.riskNameCh", type = "LOG013")
public WebResponse addDataSource(@RequestBody WebRequest<DataSourceDTO> dataSourceDTO) 

可以用三元表达式,支持同一接口提供不同功能

@OperateLog("#coreImage.body.businessTypeCode.equals('A0001') ? '个险影像查询' : '团险影像查询'")
public WebResponse listCoreImage(@Validated @RequestBody WebRequest<CoreImageDTO> coreImage)

从集合中提取某一个属性

@OperateLog(value = "'下载个险影像资料,保单号为:' + #coreImages.body.![policyNo]", type = "LOG004")
public void downloadSlisCoreImage(@RequestBody WebRequest<List<CoreImageDTO>> coreImages) 

支持java语法

@OperateLog(value = "(#blackList.body.id == null ? '新增黑名单:': '更新黑名单:') + #blackList.body.name", type = "LOG012")
public WebResponse<Integer> updateBlackList(@RequestBody WebRequest<BlackListDTO> blackList) 
特殊情况1

但是,很多时候,入参是id(如主键id),不像保单号这样有业务意义,把id拼接到操作描述没有意义,比如,我们想要看到的日志是“张三下载了文件,文件名为:《ABC制度》”,而不是“张三下载了文件,文件id为1234”。

因此,我们还需要有一套规则,根据id拿到业务名词(比如根据1234拿到“ABC制度”),也就是说,我们需要知道id跟业务名词的关系,大部分情况下,这两者属于同一张表,我们只需要知道表名、id和业务名词在表里对应的字段名。

假设我们这样定义自定义注解

public @interface OperateLog {

    /**
     * 操作描述
     * @return
     */
    String value();

    /**
     * 关联业务名称
     * @return
     */
    String relaName() default "";

    /**
     * 关联业务id
     * @return
     */
    String relaId() default "";

    /**
     * 数据库表名
     * 此字段有值时,会执行如下sql:
     *  select relaName from tableName where idFieldName = relaId
     * 然后用查出来的结果,替换value中的()占位符
     *
     * @return
     */
    String tableName() default "";

    /**
     * 关联业务id在表中的列名,默认为id
     * @return
     */
    String idFieldName() default "id";

    /**
     * 日志类型,如LOG001代表不重要的日志
     * @return
     */
    String type () default "LOG001";
}

然后这样子写

@OperateLog(value = "编辑风险: {}", relaName = "risk_name_ch", relaId = "#dataSourceDTO.body.id", idFieldName = "id", tableName = "adm_data_source", type = "LOG013")

然后执行select risk_name_ch from adm_data_source where id = #{dataSourceDTO.body.id},这样就可以拿到id对应的业务名词。

特殊情况2

有时候,同一个接口提供两个功能,这两个功能有不同的功能描述,需要从不同的表根据id查到业务名词,所以我们还需要一套备选项。

public @interface OperateLog {
    ......

    /**
     * 有时候同一个接口被不同功能使用,需要有不同的日志描述方式,因此注解的每一个属性,都有如下备选项
     */

    /**
     * 操作描述
     * @return
     */
    String value2() default "";

    /**
     * 关联业务名称
     * @return
     */
    String relaName2() default "";

    /**
     * 关联业务id
     * @return
     */
    String relaId2() default "";

    /**
     * 数据库表名
     * 此字段有值时,会执行如下sql:
     *  select relaName2 from tableName2 where idFieldName2 = relaId2
     * 然后用查出来的结果,替换value中的()占位符
     *
     * @return
     */
    String tableName2() default "";

    /**
     * 关联业务id在表中的列名,默认为id
     * @return
     */
    String idFieldName2() default "id";

    /**
     * 日志类型,如LOG001代表不重要的日志
     * @return
     */
    String type2() default "LOG001";

}

然后我们就可以这样子写

@OperateLog(value = "查看模型{}", relaId = "#views.body.views[0].path", relaName = "risk_classify", tableName = "adm_model_analysis_event", idFieldName = "view_path", type = "LOG002",
            value2 = "查看指标{}下钻模型", relaId2 = "#views.body.views[0].path", relaName2 = "indicator_name", tableName2 = "adm_indicator_info", idFieldName2 = "view_path", type2 = "LOG002")
public WebResponse showView(@RequestBody @Validated WebRequest<TableauViewDTO> views)
问题

本方案依然有很多问题
1.有些功能不经过后端,例如后端返回文件url(第三方文件服务器的url),前端点击“下载”按钮根据该url下载文件;
2.新增某个业务对象时,入参无id,只有名称等信息,无法把id记录到日志表;
3.查询sql时,无法在where加上is_valid = Y,因为无法保证所有表都有valid字段(开发人员不规范);
4.根据id查询业务名词时,可能需要跨表,本方案只支持单表查询;
5.入参是ids时,不支持select ... from ... where id in (#{ids});
6.删除接口,入参是id,若物理删除,AOP无法根据id查到对应记录;
7.有时候入参关键字有多个,本方案只支持单关键字;


image.png

8.如果前端不是用http请求,而是websocket等方式与后端通信,就无法记录日志;
10.如果是微服务之间rpc调用,也无法记录日志;
11.等等

对于上面的问题1、问题7,目前的解决方案是前端发起正常的业务http请求后,再发起一次记录日志的请求。比如问题1,前端下载文件后,再发起一次请求,把“张三下载了《ABC制度》”以及日志类型等信息发送到后端,后端直接入库。

适用场景

本方案适用于需要生成可读日志的场景,不适用于记录db操作日志等场景


image.png

以上就是方案3的技术方案,核心代码如下

OperateLog类

import java.lang.annotation.*;

/**
 * 自定义注解,用于记录用户操作日志
 */
@Inherited
@Target(ElementType.METHOD)
@Documented
@Retention(RetentionPolicy.RUNTIME)

public @interface OperateLog {

    /**
     * 操作描述
     * @return
     */
    String value();

    /**
     * 关联业务名称
     * @return
     */
    String relaName() default "";

    /**
     * 关联业务id
     * @return
     */
    String relaId() default "";

    /**
     * 数据库表名
     * 此字段有值时,会执行如下sql:
     *  select relaName from tableName where idFieldName = relaId
     * 然后用查出来的结果,替换value中的()占位符
     *
     * @return
     */
    String tableName() default "";

    /**
     * 关联业务id在表中的列名,默认为id
     * @return
     */
    String idFieldName() default "id";

    /**
     * 日志类型,如LOG001代表不重要的日志
     * @return
     */
    String type () default "LOG001";

    /**
     * 有时候同一个接口被不同功能使用,需要有不同的日志描述方式,因此注解的每一个属性,都有如下备选项
     */

    /**
     * 操作描述
     * @return
     */
    String value2() default "";

    /**
     * 关联业务名称
     * @return
     */
    String relaName2() default "";

    /**
     * 关联业务id
     * @return
     */
    String relaId2() default "";

    /**
     * 数据库表名
     * 此字段有值时,会执行如下sql:
     *  select relaName2 from tableName2 where idFieldName2 = relaId2
     * 然后用查出来的结果,替换value中的()占位符
     *
     * @return
     */
    String tableName2() default "";

    /**
     * 关联业务id在表中的列名,默认为id
     * @return
     */
    String idFieldName2() default "id";

    /**
     * 日志类型,如LOG001代表不重要的日志
     * @return
     */
    String type2() default "LOG001";

}

OperateLogAspect类

import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import scala.annotation.meta.field;

import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;

/**
 * 记录操作日志
 * 如果切点方法抛异常,不会记录操作日志
 * 使用本功能需熟悉spring EL表达式
 *
 */
@Aspect
@Component
public class OperateLogAspect {

    @Autowired
    private OperateLogMapper operateLogMapper;

    private Logger logger = LoggerFactory.getLogger(OperateLogAspect.class);

    @Pointcut(value = "@annotation(com.xxx.aspect.OperateLog)")
    private void operateLog() {

    }

    @AfterReturning("operateLog() && @annotation(operate)")
    public void around(JoinPoint point, OperateLog operate) throws Throwable{
        try {
            String account = UserUtils.getUserAccount();

            if (StringUtils.isBlank(account)) {
                logger.info("账号为空,不记录日志,方法名: {}", point.getSignature().getName());
                return ;
            }

            // 把切点入参设置到上下文
            EvaluationContext context = new StandardEvaluationContext();
            String[] parameterNames = ((MethodSignature) point.getSignature()).getParameterNames();
            Object[] args = point.getArgs();
            for (int i = 0; i <parameterNames.length; i++) {
                context.setVariable(parameterNames[i], args[i]);
            }

            // EL表达式解析器
            ExpressionParser expressionParser = new SpelExpressionParser();

            OperateLogDO operateLogDO = new OperateLogDO();

            // 数据库表名
            String tableName = operate.tableName();
            // 日志描述
            String desc = operate.value();
            // 关联业务id
            String relaId = null;
            // 日志类型
            String type = evaluate(context, expressionParser, operate.type());

            // 使用value2、relaName2等备选项
            boolean useBackup = false;

            // 解析EL表达式
            desc = evaluate(context, expressionParser, operate.value());
            // desc包含null,说明EL表达式解析结果为空,需要使用备选项
            if (desc.contains("null")) {
                useBackup = true;
            }

            // tableName不为空,需要从db查询关联业务名称
            if (StringUtils.isNotBlank(tableName) && (!useBackup))  {
                relaId = evaluate(context, expressionParser, operate.relaId());
                // relaName有可能为空,这时只记录relaId和tableName,不记录relaName
                if (StringUtils.isNotBlank(operate.relaName())) {
                    String field = operateLogMapper.selectField(operate.relaName(), tableName, operate.idFieldName(), relaId);
                    if (StringUtils.isNotBlank(field) && !Objects.equals("null", field.toLowerCase())) {
                        // 替换desc中的占位符
                        desc = desc.replace("{}", "\"" + field + "\"");
                    } else {
                        // field为null,说明EL表达式解析结果为空,需要使用备选项
                        useBackup = true;
                    }
                }
            }

            // 使用备选项
            if (useBackup && StringUtils.isNotBlank(operate.tableName2())) {
                tableName = operate.tableName2();
                desc = evaluate(context, expressionParser, operate.value2());
                type = evaluate(context, expressionParser, operate.type2());
                relaId = evaluate(context, expressionParser, operate.relaId2());
                // relaName有可能为空,这时只记录relaId和tableName,不记录relaName
                if (StringUtils.isNotBlank(operate.relaName2())) {
                    String field = operateLogMapper.selectField(operate.relaName2(), tableName, operate.idFieldName2(), relaId);
                    if (StringUtils.isNotBlank(field) && !Objects.equals("null", field.toLowerCase())) {
                        // 替换desc中的占位符
                        desc = desc.replace("{}", "\"" + field + "\"");
                    }
                }
            }

            operateLogDO.setTime(new Date());
            operateLogDO.setDescription(desc);
            operateLogDO.setAccount(account);
            operateLogDO.setRelaId(relaId);
            operateLogDO.setTableName(tableName);
            operateLogDO.setRequestIp(HttpsUtils.getRealIpAddress());
            operateLogDO.setValid(UniversalConstant.VALID);
            operateLogDO.setType(type);

            // 插入操作日志表
            operateLogMapper.insert(operateLogDO);
        } catch (Exception e) {
            logger.error("AOP记录操作日志异常", e);
        }
    }

    /**
     * 解析EL表达式
     * 如果表达式不是EL表达式但包含#,需要加上''
     * @param context 上下文
     * @param expressionParser 解析器
     * @param expressionStr EL表达式
     * @return 解析结果
     */
    private String evaluate(EvaluationContext context, ExpressionParser expressionParser, String expressionStr) {
        if (StringUtils.isBlank(expressionStr)) {
            logger.error("解析EL表达式异常,表达式为空");
            throw new AdmsException("解析EL表达式异常,表达式为空");
        }
        // 如果表达式不包含#,说明不是EL表达式,是普通字符串,直接返回
        final String elFlag = "#";
        if (!expressionStr.contains(elFlag)) {
            return expressionStr;
        }
        Expression expression = expressionParser.parseExpression(expressionStr);
        return expression.getValue(context).toString();
    }
}

OperateLogMapper.xml

    <select id="selectField" resultType="string">
        select ${column} from ${table} where ${idFieldName} = #{id} limit 1
    </select>

相关文章

  • spring 自定义注解,实现日志

    采用自定义注解实现 用户操作日志记录 简介及说明: 记录登陆用户的操作日志,目前只针对(运营管理平台)itas系统...

  • 后端日志最佳实践

    title: 后端日志最佳实践Date: 2021/07/27 09:18 什么是日志? 日志是用来记录用户操作、...

  • Linux系统上记录MYSQL操作的审计日志

    根据笔者上一篇文章—Linux系统上记录用户操作的审计日志 。本文来利用相同的方法记录MYSQL操作的审计日志。 ...

  • 日志管理

    功能说明记录不同用户在管理平台上的操作日志信息 =================================...

  • mysql limit 性能优化

    ** 本文所使用 mysql 版本为 5.6.11 ** 起因 需求:获取某用户的所有操作记录日志 日志数量虽然不...

  • dao层日志拦截记录 mybatis拦截器的使用

    近期需求中需要做一个系统日志,需求为记录到每次操作类型,操作的模块,IP,操作者昵称,操作者用户名,日志描述,操作...

  • 使用SpEL记录操作日志的详细信息

    操作日志 操作日志就是记录用户请求了什么接口,干了啥事儿。常见且简单的实现就是通过spring的aop + 自定义...

  • Linux的syslog与logrotate运用

    参考:Linux下的rsyslog系统日志梳理(用户操作记录审计) - 云+社区 - 腾讯云 参考:syslog(...

  • 运维堡垒机学习笔记

    虚拟化:应用虚拟化,xenapp 开源产品Gateone 功能 日志记录 python编写细粒度权限控制 html...

  • Loguru:Python 日志终极解决方案

    日志的重要性 日志的作用非常重要,日志可以记录用户的操作、程序的异常,还可以为数据分析提供依据,日志的存在意义就是...

网友评论

    本文标题:记录用户细粒度操作日志

    本文链接:https://www.haomeiwen.com/subject/ednfnktx.html