项目中,常需要记录用户的操作日志,常见的记录日志方案,有以下几种:
方案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.有时候入参关键字有多个,本方案只支持单关键字;
![](https://img.haomeiwen.com/i20803889/94333f9fc00b4c53.png)
8.如果前端不是用http请求,而是websocket等方式与后端通信,就无法记录日志;
10.如果是微服务之间rpc调用,也无法记录日志;
11.等等
对于上面的问题1、问题7,目前的解决方案是前端发起正常的业务http请求后,再发起一次记录日志的请求。比如问题1,前端下载文件后,再发起一次请求,把“张三下载了《ABC制度》”以及日志类型等信息发送到后端,后端直接入库。
适用场景
本方案适用于需要生成可读日志的场景,不适用于记录db操作日志等场景
![](https://img.haomeiwen.com/i20803889/7e2d91f7407ff8b6.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>
网友评论