新增:新增系统监控模块(存放系统监控模块相关功能,例如:日志管理、服务监控等),新增操作日志引擎,记录 HTTP 请求信息

This commit is contained in:
2022-12-25 13:16:15 +08:00
parent 78e84e8941
commit 727850933f
28 changed files with 1523 additions and 12 deletions

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.annotation;
import java.lang.annotation.*;
/**
* 操作日志注解(用于接口方法或类上)
*
* @author Charles7c
* @since 2022/12/23 20:00
*/
@Documented
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
/**
* 操作日志描述
*/
String value() default "";
/**
* 是否忽略日志记录
*/
boolean ignore() default false;
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import top.charles7c.cnadmin.monitor.interceptor.LogInterceptor;
/**
* 监控模块 Web MVC 配置
*
* @author Charles7c
* @since 2022/12/24 23:15
*/
@EnableWebMvc
@Configuration
@RequiredArgsConstructor
public class WebMvcMonitorConfiguration implements WebMvcConfigurer {
private final LogInterceptor logInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor);
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.config.properties;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 操作日志配置属性
*
* @author Charles7c
* @since 2022/12/24 23:04
*/
@Data
@Component
@ConfigurationProperties(prefix = "logging.operation")
public class LogProperties {
/**
* 是否启用操作日志
*/
private Boolean enabled = false;
/**
* 脱敏字段
*/
private List<String> desensitize = new ArrayList<>();
/**
* 不记录操作日志的请求方式
*/
private List<String> excludeMethods = new ArrayList<>();
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 操作日志级别枚举
*
* @author Charles7c
* @since 2022/12/25 9:09
*/
@Getter
@RequiredArgsConstructor
public enum LogLevelEnum {
/** 普通 */
INFO("普通"),
/** 错误 */
ERROR("错误"),;
/** 描述 */
private final String description;
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.filter;
import java.io.IOException;
import java.util.Objects;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.WebUtils;
/**
* 操作日志过滤器(缓存请求和响应体过滤器)
*
* <p>
* 由于 requestBody 和 responseBody 分别对应的是 InputStream 和 OutputStream由于流的特性读取完之后就无法再被使用了。 所以,需要额外缓存一次流信息。
* </p>
*
* @author Charles7c
* @since 2022/12/24 21:16
*/
@Component
public class LogFilter extends OncePerRequestFilter implements Ordered {
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 10;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 包装流,可重复读取
if (!(request instanceof ContentCachingRequestWrapper)) {
request = new ContentCachingRequestWrapper(request);
}
if (!(response instanceof ContentCachingResponseWrapper)) {
response = new ContentCachingResponseWrapper(response);
}
filterChain.doFilter(request, response);
updateResponse(response);
}
/**
* 更新响应(不操作这一步,会导致接口响应空白)
*
* @param response
* 响应对象
* @throws IOException
* /
*/
private void updateResponse(HttpServletResponse response) throws IOException {
ContentCachingResponseWrapper responseWrapper =
WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
Objects.requireNonNull(responseWrapper).copyBodyToResponse();
}
}

View File

@@ -0,0 +1,293 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.interceptor;
import java.util.Date;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import io.swagger.v3.oas.annotations.Operation;
import org.jetbrains.annotations.NotNull;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.WebUtils;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.exceptions.ExceptionUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.ServletUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONUtil;
import top.charles7c.cnadmin.common.model.dto.OperationLog;
import top.charles7c.cnadmin.common.util.IpUtils;
import top.charles7c.cnadmin.common.util.ServletUtils;
import top.charles7c.cnadmin.common.util.helper.LoginHelper;
import top.charles7c.cnadmin.common.util.holder.LogContextHolder;
import top.charles7c.cnadmin.monitor.annotation.Log;
import top.charles7c.cnadmin.monitor.config.properties.LogProperties;
import top.charles7c.cnadmin.monitor.enums.LogLevelEnum;
import top.charles7c.cnadmin.monitor.model.entity.SysLog;
/**
* 操作日志拦截器
*
* @author Charles7c
* @since 2022/12/24 21:14
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LogInterceptor implements HandlerInterceptor {
private final LogProperties operationLogProperties;
@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull Object handler) {
if (!checkIsNeedRecord(handler, request)) {
return true;
}
// 记录操作时间
this.logCreateTime();
return true;
}
@Override
public void afterCompletion(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull Object handler, Exception e) {
// 记录请求耗时及异常信息
SysLog sysLog = this.logElapsedTimeAndException();
if (sysLog == null) {
return;
}
// 记录描述
this.logDescription(sysLog, handler);
// 记录请求信息
this.logRequest(sysLog, request);
// 记录响应信息
this.logResponse(sysLog, response);
// 保存操作日志
SpringUtil.getApplicationContext().publishEvent(sysLog);
}
/**
* 记录操作时间
*/
private void logCreateTime() {
OperationLog operationLog = new OperationLog();
operationLog.setCreateUser(LoginHelper.getUserId());
operationLog.setCreateTime(new Date());
LogContextHolder.set(operationLog);
}
/**
* 记录请求耗时及异常信息
*
* @return 日志信息
*/
private SysLog logElapsedTimeAndException() {
OperationLog operationLog = LogContextHolder.get();
if (operationLog != null) {
LogContextHolder.remove();
SysLog sysLog = new SysLog();
sysLog.setCreateTime(operationLog.getCreateTime());
sysLog.setElapsedTime(System.currentTimeMillis() - sysLog.getCreateTime().getTime());
sysLog.setLogLevel(LogLevelEnum.INFO);
// 记录异常信息
Exception exception = operationLog.getException();
if (exception != null) {
sysLog.setLogLevel(LogLevelEnum.ERROR);
sysLog.setException(ExceptionUtil.stacktraceToString(operationLog.getException(), -1));
}
return sysLog;
}
return null;
}
/**
* 记录日志描述
*
* @param sysLog
* 日志信息
* @param handler
* 处理器
*/
private void logDescription(@NotNull SysLog sysLog, Object handler) {
HandlerMethod handlerMethod = (HandlerMethod)handler;
Operation methodOperation = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Operation.class);
Log methodLog = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Log.class);
if (methodOperation != null) {
sysLog.setDescription(
StrUtil.isNotBlank(methodOperation.summary()) ? methodOperation.summary() : "请在该接口方法上指定操作日志描述");
}
// 例如:@Log("获取验证码") -> 获取验证码
if (methodLog != null && StrUtil.isNotBlank(methodLog.value())) {
sysLog.setDescription(methodLog.value());
}
}
/**
* 记录请求信息
*
* @param sysLog
* 日志信息
* @param request
* 请求对象
*/
private void logRequest(@NotNull SysLog sysLog, @NotNull HttpServletRequest request) {
sysLog.setRequestUrl(StrUtil.isBlank(request.getQueryString()) ? request.getRequestURL().toString()
: request.getRequestURL().append("?").append(request.getQueryString()).toString());
sysLog.setRequestMethod(request.getMethod());
sysLog.setRequestHeader(this.desensitize(ServletUtil.getHeaderMap(request)));
String requestBody = this.getRequestBody(request);
if (StrUtil.isNotBlank(requestBody)) {
sysLog.setRequestBody(this.desensitize(
JSONUtil.isTypeJSON(requestBody) ? JSONUtil.parseObj(requestBody) : ServletUtil.getParamMap(request)));
}
sysLog.setRequestIp(ServletUtil.getClientIP(request));
sysLog.setLocation(IpUtils.getCityInfo(sysLog.getRequestIp()));
sysLog.setBrowser(ServletUtils.getBrowser(request));
sysLog.setCreateUser(sysLog.getCreateUser() == null ? LoginHelper.getUserId() : sysLog.getCreateUser());
}
/**
* 记录响应信息
*
* @param sysLog
* 日志信息
* @param response
* 响应对象
*/
private void logResponse(SysLog sysLog, HttpServletResponse response) {
sysLog.setStatusCode(response.getStatus());
sysLog.setResponseHeader(this.desensitize(ServletUtils.getHeaderMap(response)));
// 响应体(不记录非 JSON 响应数据)
String responseBody = this.getResponseBody(response);
if (StrUtil.isNotBlank(responseBody) && JSONUtil.isTypeJSON(responseBody)) {
sysLog.setResponseBody(responseBody);
}
}
/**
* 数据脱敏
*
* @param waitDesensitizeData
* 待脱敏数据
* @return 脱敏后的 JSON 字符串数据
*/
private String desensitize(Map waitDesensitizeData) {
String desensitizeDataStr = JSONUtil.toJsonStr(waitDesensitizeData);
try {
if (CollUtil.isEmpty(waitDesensitizeData)) {
return desensitizeDataStr;
}
for (String desensitizeProperty : operationLogProperties.getDesensitize()) {
waitDesensitizeData.computeIfPresent(desensitizeProperty, (k, v) -> "****************");
waitDesensitizeData.computeIfPresent(desensitizeProperty.toLowerCase(), (k, v) -> "****************");
waitDesensitizeData.computeIfPresent(desensitizeProperty.toUpperCase(), (k, v) -> "****************");
}
return JSONUtil.toJsonStr(waitDesensitizeData);
} catch (Exception ignored) {
}
return desensitizeDataStr;
}
/**
* 获取请求体
*
* @param request
* 请求对象
* @return 请求体
*/
private String getRequestBody(HttpServletRequest request) {
String requestBody = "";
ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
if (wrapper != null) {
requestBody = StrUtil.utf8Str(wrapper.getContentAsByteArray());
}
return requestBody;
}
/**
* 获取响应体
*
* @param response
* 响应对象
* @return 响应体
*/
private String getResponseBody(HttpServletResponse response) {
String responseBody = "";
ContentCachingResponseWrapper wrapper =
WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
if (wrapper != null) {
responseBody = StrUtil.utf8Str(wrapper.getContentAsByteArray());
}
return responseBody;
}
/**
* 检查是否要记录操作日志
*
* @param handler
* /
* @param request
* /
* @return true 需要记录false 不需要记录
*/
private boolean checkIsNeedRecord(Object handler, HttpServletRequest request) {
// 1、未启用时不需要记录操作日志
if (!(handler instanceof HandlerMethod) || Boolean.FALSE.equals(operationLogProperties.getEnabled())) {
return false;
}
// 2、排除不需要记录日志的接口
HandlerMethod handlerMethod = (HandlerMethod)handler;
Log methodLog = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Log.class);
// 2.1 请求方式不要求记录且请求上没有 @Log 注解,则不记录操作日志
if (operationLogProperties.getExcludeMethods().contains(request.getMethod()) && methodLog == null) {
return false;
}
// 2.2 如果接口上既没有 @Log 注解,也没有 @Operation 注解,则不记录操作日志
Operation methodOperation = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Operation.class);
if (methodLog == null && methodOperation == null) {
return false;
}
// 2.3 如果接口被隐藏,不记录操作日志
if (methodOperation != null && methodOperation.hidden()) {
return false;
}
// 2.4 如果接口上有 @Log 注解,但是要求忽略该接口,则不记录操作日志
return methodLog == null || !methodLog.ignore();
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import top.charles7c.cnadmin.monitor.model.entity.SysLog;
/**
* 操作日志 Mapper
*
* @author Charles7c
* @since 2022/12/22 21:47
*/
public interface LogMapper extends BaseMapper<SysLog> {}

View File

@@ -0,0 +1,126 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.model.entity;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import top.charles7c.cnadmin.monitor.enums.LogLevelEnum;
/**
* 操作日志实体
*
* @author Charles7c
* @since 2022/12/25 9:11
*/
@Data
@TableName("sys_log")
public class SysLog implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 日志ID
*/
@TableId
private Long logId;
/**
* 日志级别
*/
private LogLevelEnum logLevel;
/**
* 日志描述
*/
private String description;
/**
* 请求 URL
*/
private String requestUrl;
/**
* 请求方式
*/
private String requestMethod;
/**
* 请求头
*/
private String requestHeader;
/**
* 请求体
*/
private String requestBody;
/**
* 状态码
*/
private Integer statusCode;
/**
* 响应头
*/
private String responseHeader;
/**
* 响应体
*/
private String responseBody;
/**
* 请求耗时ms
*/
private Long elapsedTime;
/**
* 请求IP
*/
private String requestIp;
/**
* 操作地址
*/
private String location;
/**
* 浏览器
*/
private String browser;
/**
* 异常
*/
private String exception;
/**
* 操作人
*/
private Long createUser;
/**
* 操作时间
*/
private Date createTime;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.service;
/**
* 操作日志业务接口
*
* @author Charles7c
* @since 2022/12/23 20:12
*/
public interface LogService {
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.charles7c.cnadmin.monitor.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import top.charles7c.cnadmin.monitor.mapper.LogMapper;
import top.charles7c.cnadmin.monitor.model.entity.SysLog;
import top.charles7c.cnadmin.monitor.service.LogService;
/**
* 操作日志业务实现类
*
* @author Charles7c
* @since 2022/12/23 20:12
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LogServiceImpl implements LogService {
private final LogMapper logMapper;
@Async
@EventListener
public void save(SysLog sysLog) {
logMapper.insert(sysLog);
}
}

View File

@@ -0,0 +1,4 @@
<?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="top.charles7c.cnadmin.monitor.mapper.LogMapper">
</mapper>