初始化
This commit is contained in:
34
easyflow-modules/easyflow-module-log/pom.xml
Normal file
34
easyflow-modules/easyflow-module-log/pom.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-modules</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<name>easyflow-module-log</name>
|
||||
<artifactId>easyflow-module-log</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.mybatis-flex</groupId>
|
||||
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-satoken</artifactId>
|
||||
</dependency>
|
||||
<!-- Javassist for line number -->
|
||||
<dependency>
|
||||
<groupId>org.javassist</groupId>
|
||||
<artifactId>javassist</artifactId>
|
||||
<version>3.29.2-GA</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
package tech.easyflow.log;
|
||||
|
||||
public class ActionTypes {
|
||||
|
||||
public static final String INSERT = "INSERT";
|
||||
public static final String DELETE = "DELETE";
|
||||
public static final String UPDATE = "UPDATE";
|
||||
public static final String QUERY = "QUERY";
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tech.easyflow.log;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import tech.easyflow.common.util.RequestUtil;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
|
||||
import tech.easyflow.log.annotation.LogRecord;
|
||||
import tech.easyflow.log.entity.WriteLog;
|
||||
import tech.easyflow.log.mapper.WriteLogMapper;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
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.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.Enumeration;
|
||||
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class LogAspect {
|
||||
|
||||
private static final int maxLengthOfParaValue = 512;
|
||||
|
||||
private final WriteLogMapper logService;
|
||||
private final LogRecordProperties config;
|
||||
|
||||
public LogAspect(WriteLogMapper logService, LogRecordProperties config) {
|
||||
this.logService = logService;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *) " +
|
||||
"|| execution(* tech.easyflow.common.web.controller.BaseCurdController.*(..))")
|
||||
public void pointcut() {
|
||||
}
|
||||
|
||||
@Around("pointcut()")
|
||||
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
|
||||
String servletPath = request.getServletPath();
|
||||
|
||||
//匹配前缀
|
||||
if (StringUtil.hasText(config.getRecordActionPrefix()) && !servletPath.startsWith(config.getRecordActionPrefix())) {
|
||||
return proceedingJoinPoint.proceed();
|
||||
}
|
||||
|
||||
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
|
||||
Class<?> controllerClass = signature.getDeclaringType();
|
||||
Method method = signature.getMethod();
|
||||
String params = getRequestParamsString(request);
|
||||
|
||||
try {
|
||||
return proceedingJoinPoint.proceed();
|
||||
} finally {
|
||||
WriteLog sysLog = new WriteLog();
|
||||
LogRecord logRecord = method.getAnnotation(LogRecord.class);
|
||||
if (StpUtil.isLogin()) {
|
||||
|
||||
BigInteger accountId = SaTokenUtil.getLoginAccount().getId();
|
||||
sysLog.setAccountId(accountId);
|
||||
}
|
||||
sysLog.setActionName(buildActionName(logRecord, method));
|
||||
sysLog.setActionType(logRecord != null ? logRecord.actionType() : null);
|
||||
sysLog.setActionClass(controllerClass.getName());
|
||||
sysLog.setActionMethod(method.getName());
|
||||
sysLog.setActionUrl(request.getRequestURL().toString());
|
||||
sysLog.setActionIp(RequestUtil.getIpAddress(request));
|
||||
sysLog.setActionParams(params);
|
||||
sysLog.setStatus(1);
|
||||
sysLog.setCreated(new Date());
|
||||
|
||||
logService.insert(sysLog);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildActionName(LogRecord logRecord, Method method) {
|
||||
if (logRecord != null && StringUtil.hasText(logRecord.value())) {
|
||||
return logRecord.value();
|
||||
} else {
|
||||
//todo 这里可以通过方法名,去获取 Controller 的实体类,在获取其表备注信息,进一步进行判断
|
||||
return method.getName();
|
||||
}
|
||||
}
|
||||
|
||||
private String getRequestParamsString(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Enumeration<String> e = request.getParameterNames();
|
||||
if (e.hasMoreElements()) {
|
||||
while (e.hasMoreElements()) {
|
||||
String name = e.nextElement();
|
||||
String[] values = request.getParameterValues(name);
|
||||
if (values.length == 1) {
|
||||
sb.append(name).append("=");
|
||||
if (values[0] != null && values[0].length() > maxLengthOfParaValue) {
|
||||
sb.append(values[0], 0, maxLengthOfParaValue).append("...");
|
||||
} else {
|
||||
sb.append(values[0]);
|
||||
}
|
||||
} else {
|
||||
sb.append(name).append("[]={");
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
if (i > 0) {
|
||||
sb.append(",");
|
||||
}
|
||||
sb.append(values[i]);
|
||||
}
|
||||
sb.append("}");
|
||||
}
|
||||
sb.append(" ");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package tech.easyflow.log;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "easyflow.log-record")
|
||||
public class LogRecordProperties {
|
||||
|
||||
private String recordActionPrefix;
|
||||
|
||||
public String getRecordActionPrefix() {
|
||||
return recordActionPrefix;
|
||||
}
|
||||
|
||||
public void setRecordActionPrefix(String recordActionPrefix) {
|
||||
this.recordActionPrefix = recordActionPrefix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tech.easyflow.log.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* @author michael yang (fuhai999@gmail.com)
|
||||
*/
|
||||
@Inherited
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.METHOD})
|
||||
public @interface LogRecord {
|
||||
|
||||
String value();
|
||||
|
||||
String actionType() default "";
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package tech.easyflow.log.annotation;
|
||||
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 标记后,LogReporterDisabled 将跳过此方法或类的日志输出
|
||||
*/
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface LogReporterDisabled {
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package tech.easyflow.log.config;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import tech.easyflow.log.reporter.ActionLogReporterProperties;
|
||||
import tech.easyflow.log.reporter.ActionReportInterceptor;
|
||||
|
||||
@MapperScan("tech.easyflow.log.mapper")
|
||||
@Configuration
|
||||
public class LogModuleConfig implements WebMvcConfigurer {
|
||||
|
||||
private final ActionLogReporterProperties logProperties;
|
||||
private final ActionReportInterceptor actionReportInterceptor;
|
||||
|
||||
public LogModuleConfig(ActionLogReporterProperties logProperties,
|
||||
ActionReportInterceptor actionReportInterceptor) {
|
||||
this.logProperties = logProperties;
|
||||
this.actionReportInterceptor = actionReportInterceptor;
|
||||
|
||||
System.out.println("启用模块 >>>>>>>>>> module-log");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 注册日志拦截器
|
||||
*/
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
if (!logProperties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
registry.addInterceptor(actionReportInterceptor)
|
||||
.addPathPatterns(logProperties.getIncludePatterns().toArray(new String[0]))
|
||||
.excludePathPatterns(logProperties.getExcludePatterns().toArray(new String[0]));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package tech.easyflow.log.entity;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
|
||||
@Table(value = "tb_sys_log", comment = "操作日志表")
|
||||
public class WriteLog implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||
private BigInteger id;
|
||||
|
||||
/**
|
||||
* 操作人
|
||||
*/
|
||||
@Column(comment = "操作人")
|
||||
private BigInteger accountId;
|
||||
|
||||
/**
|
||||
* 操作名称
|
||||
*/
|
||||
@Column(comment = "操作名称")
|
||||
private String actionName;
|
||||
|
||||
/**
|
||||
* 操作的类型
|
||||
*/
|
||||
@Column(comment = "操作的类型")
|
||||
private String actionType;
|
||||
|
||||
/**
|
||||
* 操作涉及的类
|
||||
*/
|
||||
@Column(comment = "操作涉及的类")
|
||||
private String actionClass;
|
||||
|
||||
/**
|
||||
* 操作涉及的方法
|
||||
*/
|
||||
@Column(comment = "操作涉及的方法")
|
||||
private String actionMethod;
|
||||
|
||||
/**
|
||||
* 操作涉及的 URL 地址
|
||||
*/
|
||||
@Column(comment = "操作涉及的 URL 地址")
|
||||
private String actionUrl;
|
||||
|
||||
/**
|
||||
* 操作涉及的用户 IP 地址
|
||||
*/
|
||||
@Column(comment = "操作涉及的用户 IP 地址")
|
||||
private String actionIp;
|
||||
|
||||
/**
|
||||
* 操作请求参数
|
||||
*/
|
||||
@Column(comment = "操作请求参数")
|
||||
private String actionParams;
|
||||
|
||||
/**
|
||||
* 操作请求body
|
||||
*/
|
||||
@Column(comment = "操作请求body")
|
||||
private String actionBody;
|
||||
|
||||
/**
|
||||
* 操作状态 1 成功 9 失败
|
||||
*/
|
||||
@Column(comment = "操作状态 1 成功 9 失败")
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 操作时间
|
||||
*/
|
||||
@Column(comment = "操作时间")
|
||||
private Date created;
|
||||
|
||||
public BigInteger getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(BigInteger id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public BigInteger getAccountId() {
|
||||
return accountId;
|
||||
}
|
||||
|
||||
public void setAccountId(BigInteger accountId) {
|
||||
this.accountId = accountId;
|
||||
}
|
||||
|
||||
public String getActionName() {
|
||||
return actionName;
|
||||
}
|
||||
|
||||
public void setActionName(String actionName) {
|
||||
this.actionName = actionName;
|
||||
}
|
||||
|
||||
public String getActionType() {
|
||||
return actionType;
|
||||
}
|
||||
|
||||
public void setActionType(String actionType) {
|
||||
this.actionType = actionType;
|
||||
}
|
||||
|
||||
public String getActionClass() {
|
||||
return actionClass;
|
||||
}
|
||||
|
||||
public void setActionClass(String actionClass) {
|
||||
this.actionClass = actionClass;
|
||||
}
|
||||
|
||||
public String getActionMethod() {
|
||||
return actionMethod;
|
||||
}
|
||||
|
||||
public void setActionMethod(String actionMethod) {
|
||||
this.actionMethod = actionMethod;
|
||||
}
|
||||
|
||||
public String getActionUrl() {
|
||||
return actionUrl;
|
||||
}
|
||||
|
||||
public void setActionUrl(String actionUrl) {
|
||||
this.actionUrl = actionUrl;
|
||||
}
|
||||
|
||||
public String getActionIp() {
|
||||
return actionIp;
|
||||
}
|
||||
|
||||
public void setActionIp(String actionIp) {
|
||||
this.actionIp = actionIp;
|
||||
}
|
||||
|
||||
public String getActionParams() {
|
||||
return actionParams;
|
||||
}
|
||||
|
||||
public void setActionParams(String actionParams) {
|
||||
this.actionParams = actionParams;
|
||||
}
|
||||
|
||||
public String getActionBody() {
|
||||
return actionBody;
|
||||
}
|
||||
|
||||
public void setActionBody(String actionBody) {
|
||||
this.actionBody = actionBody;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Date getCreated() {
|
||||
return created;
|
||||
}
|
||||
|
||||
public void setCreated(Date created) {
|
||||
this.created = created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package tech.easyflow.log.mapper;
|
||||
|
||||
import tech.easyflow.log.entity.WriteLog;
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
|
||||
/**
|
||||
* 映射层。
|
||||
*
|
||||
* @author michael
|
||||
* @since 2024-01-28
|
||||
*/
|
||||
public interface WriteLogMapper extends BaseMapper<WriteLog> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package tech.easyflow.log.reporter;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "easyflow.log.reporter")
|
||||
public class ActionLogReporterProperties {
|
||||
|
||||
/**
|
||||
* 是否启用 Action 报告
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 采样率(0.0 ~ 1.0),1.0 = 100%
|
||||
*/
|
||||
private double sampleRate = 1.0;
|
||||
|
||||
/**
|
||||
* 包含的路径模式
|
||||
*/
|
||||
private List<String> includePatterns = Arrays.asList("/**");
|
||||
|
||||
/**
|
||||
* 排除的路径模式
|
||||
*/
|
||||
private List<String> excludePatterns = Arrays.asList(
|
||||
"/static/**",
|
||||
"/assets/**",
|
||||
"/js/**",
|
||||
"/css/**",
|
||||
"/images/**",
|
||||
"/favicon.ico",
|
||||
"/actuator/**",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"*.png",
|
||||
"*.jpg",
|
||||
"*.gif",
|
||||
"*.ico"
|
||||
);
|
||||
|
||||
|
||||
// getter and setter
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public double getSampleRate() {
|
||||
return sampleRate;
|
||||
}
|
||||
|
||||
public void setSampleRate(double sampleRate) {
|
||||
this.sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
public List<String> getIncludePatterns() {
|
||||
return includePatterns;
|
||||
}
|
||||
|
||||
public void setIncludePatterns(List<String> includePatterns) {
|
||||
this.includePatterns = includePatterns;
|
||||
}
|
||||
|
||||
public List<String> getExcludePatterns() {
|
||||
return excludePatterns;
|
||||
}
|
||||
|
||||
public void setExcludePatterns(List<String> excludePatterns) {
|
||||
this.excludePatterns = excludePatterns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package tech.easyflow.log.reporter;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONWriter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||
import tech.easyflow.common.util.RequestUtil;
|
||||
import tech.easyflow.log.annotation.LogReporterDisabled;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
|
||||
@Component
|
||||
public class ActionReportInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger("ACTION_REPORT");
|
||||
|
||||
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
static {
|
||||
SDF.setTimeZone(TimeZone.getTimeZone("GMT+8")); // 北京时间
|
||||
}
|
||||
|
||||
private static final String DIVIDER = "─────────────────────────────────────────────────────────────────────────────";
|
||||
private static final int MAX_JSON_LENGTH = 512;
|
||||
|
||||
private static final List<String> SENSITIVE_KEYS = Arrays.asList("password", "passwd", "secret", "token", "key");
|
||||
|
||||
private final ActionLogReporterProperties logProperties;
|
||||
|
||||
public ActionReportInterceptor(ActionLogReporterProperties logProperties) {
|
||||
this.logProperties = logProperties;
|
||||
}
|
||||
|
||||
// 存储是否需要记录日志的标志
|
||||
private static final ThreadLocal<Boolean> SHOULD_LOG = ThreadLocal.withInitial(() -> false);
|
||||
// 存储开始时间
|
||||
private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
|
||||
// 存储 HandlerMethod,供 afterCompletion 使用
|
||||
private static final ThreadLocal<HandlerMethod> HANDLER_METHOD = new ThreadLocal<>();
|
||||
// 存储 ModelAndView,供 afterCompletion 使用
|
||||
private static final ThreadLocal<ModelAndView> MODEL_AND_VIEW = new ThreadLocal<>();
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
// 默认不记录
|
||||
SHOULD_LOG.set(false);
|
||||
|
||||
if (!logProperties.isEnabled() || !(handler instanceof HandlerMethod)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
HandlerMethod hm = (HandlerMethod) handler;
|
||||
Method method = hm.getMethod();
|
||||
|
||||
// 采样
|
||||
if (logProperties.getSampleRate() < 1.0 && Math.random() > logProperties.getSampleRate()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 排除 LogReporterDisabled 注解
|
||||
if (method.isAnnotationPresent(LogReporterDisabled.class) ||
|
||||
method.getDeclaringClass().isAnnotationPresent(LogReporterDisabled.class)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 设置标志:需要记录日志
|
||||
SHOULD_LOG.set(true);
|
||||
START_TIME.set(System.currentTimeMillis());
|
||||
HANDLER_METHOD.set(hm);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
|
||||
// 仅暂存 modelAndView,供 afterCompletion 使用
|
||||
if (SHOULD_LOG.get()) {
|
||||
MODEL_AND_VIEW.set(modelAndView);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||
try {
|
||||
if (!Boolean.TRUE.equals(SHOULD_LOG.get())) {
|
||||
return;
|
||||
}
|
||||
|
||||
HandlerMethod hm = HANDLER_METHOD.get();
|
||||
if (hm == null) return;
|
||||
|
||||
Method method = hm.getMethod();
|
||||
ModelAndView modelAndView = MODEL_AND_VIEW.get();
|
||||
|
||||
StringBuilder sb = new StringBuilder(2048);
|
||||
sb.append("\n").append(DIVIDER).append('\n');
|
||||
|
||||
String timestamp = SDF.format(new Date());
|
||||
sb.append("EasyFlow action report -------- ").append(timestamp).append(" -------------------------\n");
|
||||
sb.append("Request : ").append(request.getMethod())
|
||||
.append(" ").append(request.getRequestURI()).append("\n");
|
||||
|
||||
// 打印参数(GET / POST 表单参数),脱敏
|
||||
Map<String, String[]> params = request.getParameterMap();
|
||||
if (!params.isEmpty()) {
|
||||
Map<String, Object> maskedParams = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String[]> entry : params.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String[] values = entry.getValue();
|
||||
if (values != null && values.length == 1) {
|
||||
maskedParams.put(key, isSensitive(key) ? "***" : values[0]);
|
||||
} else {
|
||||
maskedParams.put(key, isSensitive(key) ? "***" : values);
|
||||
}
|
||||
}
|
||||
sb.append("Params : ").append(JSON.toJSONString(maskedParams)).append("\n");
|
||||
}
|
||||
|
||||
// ====== 读取 POST Body ======
|
||||
String methodStr = request.getMethod();
|
||||
if ("POST".equalsIgnoreCase(methodStr) || "PUT".equalsIgnoreCase(methodStr) || "PATCH".equalsIgnoreCase(methodStr)) {
|
||||
String body = RequestUtil.readBodyString(request);
|
||||
if (body != null && !body.trim().isEmpty()) {
|
||||
try {
|
||||
Object obj = JSON.parse(body);
|
||||
if (obj instanceof Map) {
|
||||
obj = maskSensitiveValues((Map<String, Object>) obj);
|
||||
}
|
||||
String maskedBody = JSON.toJSONString(obj);
|
||||
if (maskedBody.length() > MAX_JSON_LENGTH) {
|
||||
maskedBody = maskedBody.substring(0, MAX_JSON_LENGTH) + " ... (truncated)";
|
||||
}
|
||||
sb.append("Body : ").append(maskedBody.replace("\n", " ")).append("\n");
|
||||
} catch (Exception e) {
|
||||
String truncated = body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
sb.append("Body : ").append(truncated).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Controller & Method 位置(含行号)
|
||||
Class<?> clazz = method.getDeclaringClass();
|
||||
String fileName = clazz.getSimpleName() + ".java";
|
||||
int lineNumber = JavassistLineNumUtils.getLineNumber(method);
|
||||
String lineStr = lineNumber > 0 ? String.valueOf(lineNumber) : "?";
|
||||
String controllerLocation = clazz.getName() + ".(" + fileName + ":" + lineStr + ")";
|
||||
sb.append("Controller : ").append(controllerLocation).append("\n");
|
||||
|
||||
// 构建方法签名
|
||||
sb.append("Method : ").append(buildMethodSignature(method));
|
||||
|
||||
// ====== 处理 ModelAndView ======
|
||||
if (modelAndView != null && modelAndView.getViewName() != null) {
|
||||
sb.append('\n').append("Render : ").append(modelAndView.getViewName());
|
||||
|
||||
Map<String, Object> model = modelAndView.getModel();
|
||||
if (!model.isEmpty()) {
|
||||
sb.append("\nModel : ");
|
||||
try {
|
||||
String json = JSON.toJSONString(maskSensitiveValues(model), JSONWriter.Feature.WriteMapNullValue);
|
||||
if (json.length() > MAX_JSON_LENGTH) {
|
||||
json = json.substring(0, MAX_JSON_LENGTH) + " ... (truncated)";
|
||||
}
|
||||
sb.append(json.replace("\n", " "));
|
||||
} catch (Exception e) {
|
||||
sb.append("(failed to serialize)");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sb.append('\n').append("Render : (none)");
|
||||
}
|
||||
|
||||
// ====== 尝试获取最终响应体 ======
|
||||
if (response instanceof ContentCachingResponseWrapper) {
|
||||
ContentCachingResponseWrapper wrapper = (ContentCachingResponseWrapper) response;
|
||||
byte[] body = wrapper.getContentAsByteArray();
|
||||
if (body.length > 0) {
|
||||
String content = new String(body, 0, Math.min(body.length, 1024), StandardCharsets.UTF_8);
|
||||
try {
|
||||
Object obj = JSON.parse(content);
|
||||
if (obj instanceof Map) {
|
||||
obj = maskSensitiveValues((Map<String, Object>) obj);
|
||||
}
|
||||
String masked = JSON.toJSONString(obj);
|
||||
if (masked.length() > MAX_JSON_LENGTH) {
|
||||
masked = masked.substring(0, MAX_JSON_LENGTH) + " ... (truncated)";
|
||||
}
|
||||
sb.append('\n').append("Response : ").append(masked.replace("\n", " "));
|
||||
} catch (Exception e) {
|
||||
String truncated = content.length() > 200 ? content.substring(0, 200) + "..." : content;
|
||||
sb.append('\n').append("Response : ").append(truncated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 异常信息 ======
|
||||
if (ex != null) {
|
||||
sb.append('\n')
|
||||
.append("Status : FAILED\n")
|
||||
.append("Exception : ").append(ex.getClass().getSimpleName())
|
||||
.append(": ").append(ex.getMessage() != null ? ex.getMessage().split("\n")[0] : "Unknown");
|
||||
}
|
||||
|
||||
// ====== 耗时 ======
|
||||
Long start = START_TIME.get();
|
||||
long took = start != null ? System.currentTimeMillis() - start : -1;
|
||||
if (took >= 0) {
|
||||
sb.append('\n')
|
||||
.append("----------------------------------- took ").append(took).append(" ms --------------------------------");
|
||||
} else {
|
||||
sb.append('\n').append("----------------------------------- took ? ms --------------------------------");
|
||||
}
|
||||
|
||||
sb.append('\n').append(DIVIDER);
|
||||
|
||||
logger.info(sb.toString());
|
||||
|
||||
} finally {
|
||||
// 清理 ThreadLocal
|
||||
SHOULD_LOG.remove();
|
||||
START_TIME.remove();
|
||||
HANDLER_METHOD.remove();
|
||||
MODEL_AND_VIEW.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== 工具方法 ======================
|
||||
|
||||
private boolean isSensitive(String key) {
|
||||
return SENSITIVE_KEYS.stream().anyMatch(s -> key.toLowerCase().contains(s));
|
||||
}
|
||||
|
||||
private Map<String, Object> maskSensitiveValues(Map<String, Object> model) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> entry : model.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
|
||||
if (isSensitive(key)) {
|
||||
result.put(key, "***");
|
||||
} else if (value instanceof Map) {
|
||||
result.put(key, maskSensitiveValues((Map<String, Object>) value));
|
||||
} else {
|
||||
result.put(key, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建方法签名:methodName(paramType paramName, ...)
|
||||
*/
|
||||
private String buildMethodSignature(Method method) {
|
||||
StringBuilder sig = new StringBuilder();
|
||||
sig.append(method.getName()).append("(");
|
||||
|
||||
Parameter[] parameters = method.getParameters();
|
||||
for (int i = 0; i < parameters.length; i++) {
|
||||
Parameter param = parameters[i];
|
||||
sig.append(param.getType().getSimpleName())
|
||||
.append(" ")
|
||||
.append(param.getName());
|
||||
|
||||
if (i < parameters.length - 1) {
|
||||
sig.append(", ");
|
||||
}
|
||||
}
|
||||
sig.append(")");
|
||||
return sig.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package tech.easyflow.log.reporter;
|
||||
|
||||
|
||||
import javassist.ClassPool;
|
||||
import javassist.CtClass;
|
||||
import javassist.CtMethod;
|
||||
import javassist.NotFoundException;
|
||||
import javassist.bytecode.MethodInfo;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class JavassistLineNumUtils {
|
||||
|
||||
private static final ClassPool CLASS_POOL = ClassPool.getDefault();
|
||||
private static final ConcurrentHashMap<String, Integer> LINE_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取方法在源码中的起始行号
|
||||
*/
|
||||
public static int getLineNumber(java.lang.reflect.Method method) {
|
||||
String key = method.getDeclaringClass().getName() + "." + method.getName();
|
||||
return LINE_CACHE.computeIfAbsent(key, k -> {
|
||||
try {
|
||||
CtClass ctClass = CLASS_POOL.get(method.getDeclaringClass().getName());
|
||||
CtClass[] params = toCtClasses(method.getParameterTypes());
|
||||
CtMethod ctMethod = ctClass.getDeclaredMethod(method.getName(), params);
|
||||
|
||||
MethodInfo methodInfo = ctMethod.getMethodInfo();
|
||||
return methodInfo.getLineNumber(0);
|
||||
|
||||
} catch (Exception e) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static CtClass[] toCtClasses(Class<?>[] parameterTypes) throws NotFoundException {
|
||||
CtClass[] result = new CtClass[parameterTypes.length];
|
||||
for (int i = 0; i < parameterTypes.length; i++) {
|
||||
result[i] = CLASS_POOL.get(parameterTypes[i].getName());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package tech.easyflow.log.reporter;
|
||||
|
||||
import jakarta.servlet.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||
import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE;
|
||||
|
||||
/**
|
||||
* 响应缓存 Filter,支持基于路径的排除规则
|
||||
*/
|
||||
@Component
|
||||
@Order(HIGHEST_PRECEDENCE)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "easyflow.log.reporter",
|
||||
name = "enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = true // 默认开启
|
||||
)
|
||||
public class ResponseCachingFilter implements Filter {
|
||||
|
||||
@Autowired
|
||||
private ActionLogReporterProperties logProperties;
|
||||
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
HttpServletRequest httpRequest = (HttpServletRequest) request;
|
||||
String uri = httpRequest.getRequestURI();
|
||||
String method = httpRequest.getMethod();
|
||||
|
||||
// 1如果是 OPTIONS 请求,跳过(通常为预检)
|
||||
if ("OPTIONS".equalsIgnoreCase(method)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// // 检查是否为 SSE 请求
|
||||
// if (isSseRequest(httpRequest)) {
|
||||
// chain.doFilter(request, response);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// 检查是否匹配排除路径
|
||||
if (isExcluded(uri)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否匹配包含路径(一般为 /**,可省略)
|
||||
if (!isIncluded(uri)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(httpRequest);
|
||||
if (isSseRequest(httpRequest)) {
|
||||
// SSE 请求不缓存
|
||||
chain.doFilter(requestWrapper, response);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(httpResponse);
|
||||
try {
|
||||
chain.doFilter(requestWrapper, responseWrapper);
|
||||
} finally {
|
||||
responseWrapper.copyBodyToResponse(); // 必须调用
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isExcluded(String uri) {
|
||||
return logProperties.getExcludePatterns().stream().anyMatch(p -> match(uri, p));
|
||||
}
|
||||
|
||||
private boolean isIncluded(String uri) {
|
||||
return logProperties.getIncludePatterns().stream().anyMatch(p -> match(uri, p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 SSE 请求(基于标准 Accept 头)
|
||||
*/
|
||||
private boolean isSseRequest(HttpServletRequest request) {
|
||||
String accept = request.getHeader("Accept");
|
||||
return accept != null && accept.contains("text/event-stream");
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的路径匹配(支持 * 和 **)
|
||||
* 注意:这里简化实现,生产可替换为 AntPathMatcher
|
||||
*/
|
||||
private boolean match(String path, String pattern) {
|
||||
if (pattern.equals("/**")) {
|
||||
return true;
|
||||
}
|
||||
if (pattern.endsWith("/**")) {
|
||||
String prefix = pattern.substring(0, pattern.length() - 3);
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
if (pattern.endsWith("*")) {
|
||||
String prefix = pattern.substring(0, pattern.length() - 1);
|
||||
return path.startsWith(prefix);
|
||||
}
|
||||
if (pattern.contains("*") && !pattern.contains("/**")) {
|
||||
// 支持 *.js, *.css
|
||||
String p = pattern.replace("*", "").replace(".", "\\.");
|
||||
return path.matches(".*" + p + ".*");
|
||||
}
|
||||
return path.equals(pattern);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user