7 changed files with 719 additions and 0 deletions
@ -0,0 +1,446 @@ |
|||||||
|
package com.biutag.supervision.aop; |
||||||
|
|
||||||
|
import com.biutag.supervision.common.UserContextHolder; |
||||||
|
import com.biutag.supervision.constants.RedisKeyConstants; |
||||||
|
import com.biutag.supervision.pojo.Result; |
||||||
|
import com.biutag.supervision.pojo.domain.AppUser; |
||||||
|
import com.biutag.supervision.pojo.entity.SystemLog; |
||||||
|
import com.biutag.supervision.pojo.model.UserAuth; |
||||||
|
import com.biutag.supervision.pojo.vo.TokenVo; |
||||||
|
import com.biutag.supervision.service.SystemLogService; |
||||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode; |
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode; |
||||||
|
import jakarta.servlet.ServletRequest; |
||||||
|
import jakarta.servlet.ServletResponse; |
||||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||||
|
import jakarta.servlet.http.HttpServletResponse; |
||||||
|
import lombok.RequiredArgsConstructor; |
||||||
|
import lombok.extern.slf4j.Slf4j; |
||||||
|
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.core.annotation.Order; |
||||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||||
|
import org.springframework.stereotype.Component; |
||||||
|
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.io.InputStream; |
||||||
|
import java.io.OutputStream; |
||||||
|
import java.io.Reader; |
||||||
|
import java.io.Writer; |
||||||
|
import java.lang.reflect.Array; |
||||||
|
import java.time.LocalDateTime; |
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Locale; |
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
/** |
||||||
|
* 统一记录进入 Controller 调用链的接口访问日志。 |
||||||
|
* <p> |
||||||
|
* 日志切面优先级高于现有参数检查切面,因此参数检查抛出的业务异常也可以被记录; |
||||||
|
* 在 Spring MVC 调用 Controller 之前就被鉴权拦截的请求不会进入本切面。 |
||||||
|
*/ |
||||||
|
@Slf4j |
||||||
|
@Aspect |
||||||
|
@Component |
||||||
|
@Order(-100) |
||||||
|
@RequiredArgsConstructor |
||||||
|
public class SystemLogAspect { |
||||||
|
|
||||||
|
private static final int SUCCESS = 1; |
||||||
|
private static final int FAILURE = 0; |
||||||
|
private static final int MAX_PARAMS_LENGTH = 4000; |
||||||
|
private static final int MAX_ERROR_LENGTH = 1000; |
||||||
|
private static final String MASK_VALUE = "******"; |
||||||
|
private static final String TRUNCATED_MARK = "...[内容已截断]"; |
||||||
|
|
||||||
|
private final SystemLogService systemLogService; |
||||||
|
private final ObjectMapper objectMapper; |
||||||
|
private final RedisTemplate<Object, Object> redisTemplate; |
||||||
|
|
||||||
|
/** |
||||||
|
* 只拦截项目 Controller 包中的方法,Service 和 Mapper 调用不会触发日志递归。 |
||||||
|
*/ |
||||||
|
@Pointcut("execution(* com.biutag.supervision.controller..*(..))") |
||||||
|
public void controllerPointcut() { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 包裹 Controller 调用并在 finally 中保存日志,确保业务异常场景也能记录。 |
||||||
|
*/ |
||||||
|
@Around("controllerPointcut()") |
||||||
|
public Object recordSystemLog(ProceedingJoinPoint joinPoint) throws Throwable { |
||||||
|
ServletRequestAttributes attributes = getRequestAttributes(); |
||||||
|
HttpServletRequest request = attributes == null ? null : attributes.getRequest(); |
||||||
|
HttpServletResponse response = attributes == null ? null : attributes.getResponse(); |
||||||
|
LocalDateTime logTime = LocalDateTime.now(); |
||||||
|
long startTime = System.nanoTime(); |
||||||
|
|
||||||
|
UserAuth currentUser = resolveCurrentUser(); |
||||||
|
String requestParams = buildRequestParams(joinPoint, request); |
||||||
|
Object result = null; |
||||||
|
Throwable throwable = null; |
||||||
|
|
||||||
|
try { |
||||||
|
result = joinPoint.proceed(); |
||||||
|
return result; |
||||||
|
} catch (Throwable ex) { |
||||||
|
throwable = ex; |
||||||
|
throw ex; |
||||||
|
} finally { |
||||||
|
long executionTime = (System.nanoTime() - startTime) / 1_000_000L; |
||||||
|
UserAuth logUser = currentUser == null ? resolveLoginUser(result) : currentUser; |
||||||
|
saveLogSafely(request, response, logUser, requestParams, result, throwable, executionTime, logTime); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private ServletRequestAttributes getRequestAttributes() { |
||||||
|
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { |
||||||
|
return attributes; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 公开接口或外部接口可能没有后台登录态,此时用户字段保持为空,由前端显示为匿名调用。 |
||||||
|
*/ |
||||||
|
private UserAuth resolveCurrentUser() { |
||||||
|
try { |
||||||
|
return UserContextHolder.getCurrentUser(); |
||||||
|
} catch (RuntimeException ignored) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 登录请求进入 Controller 前尚未携带 Token。登录成功后,从返回值中临时提取 Token, |
||||||
|
* 再读取登录流程刚写入的 Redis 会话,从而为账号登录和数字证书登录回填真实请求人。 |
||||||
|
* App 登录已经直接返回 UserAuth,优先复用该对象,不保存任何响应内容。 |
||||||
|
*/ |
||||||
|
private UserAuth resolveLoginUser(Object result) { |
||||||
|
if (!(result instanceof Result<?> apiResult) || apiResult.getCode() != HttpServletResponse.SC_OK) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
Object data = apiResult.getData(); |
||||||
|
if (data instanceof AppUser appUser && appUser.getUser() != null) { |
||||||
|
return appUser.getUser(); |
||||||
|
} |
||||||
|
|
||||||
|
String token = null; |
||||||
|
if (data instanceof TokenVo tokenVo) { |
||||||
|
token = tokenVo.getToken(); |
||||||
|
} else if (data instanceof AppUser appUser) { |
||||||
|
token = appUser.getToken(); |
||||||
|
} |
||||||
|
if (token == null || token.isBlank()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
Object user = redisTemplate.opsForValue().get(String.format(RedisKeyConstants.LOGIN_USERINFO_KEY, token)); |
||||||
|
return user instanceof UserAuth userAuth ? userAuth : null; |
||||||
|
} catch (RuntimeException ex) { |
||||||
|
log.warn("登录日志请求人解析失败:{}", ex.getMessage()); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 同时采集原始 query/form 参数和 Controller 方法参数,以覆盖查询、路径变量及 JSON 请求体。 |
||||||
|
*/ |
||||||
|
private String buildRequestParams(ProceedingJoinPoint joinPoint, HttpServletRequest request) { |
||||||
|
try { |
||||||
|
ObjectNode rootNode = objectMapper.createObjectNode(); |
||||||
|
appendRequestParameters(rootNode, request); |
||||||
|
appendMethodArguments(rootNode, joinPoint); |
||||||
|
sanitizeNode(rootNode); |
||||||
|
|
||||||
|
if (rootNode.isEmpty()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return truncate(objectMapper.writeValueAsString(rootNode), MAX_PARAMS_LENGTH, TRUNCATED_MARK); |
||||||
|
} catch (Exception ex) { |
||||||
|
log.warn("系统日志请求参数解析失败:{}", ex.getMessage()); |
||||||
|
return "[请求参数解析失败]"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void appendRequestParameters(ObjectNode rootNode, HttpServletRequest request) { |
||||||
|
if (request == null || request.getParameterMap().isEmpty()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
ObjectNode queryNode = rootNode.putObject("query"); |
||||||
|
request.getParameterMap().forEach((key, values) -> { |
||||||
|
if (values == null || values.length == 0) { |
||||||
|
queryNode.putNull(key); |
||||||
|
} else if (values.length == 1) { |
||||||
|
queryNode.put(key, values[0]); |
||||||
|
} else { |
||||||
|
ArrayNode arrayNode = queryNode.putArray(key); |
||||||
|
for (String value : values) { |
||||||
|
arrayNode.add(value); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private void appendMethodArguments(ObjectNode rootNode, ProceedingJoinPoint joinPoint) { |
||||||
|
Object[] arguments = joinPoint.getArgs(); |
||||||
|
if (arguments == null || arguments.length == 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames(); |
||||||
|
ObjectNode argumentNode = rootNode.putObject("arguments"); |
||||||
|
for (int index = 0; index < arguments.length; index++) { |
||||||
|
Object argument = arguments[index]; |
||||||
|
if (shouldIgnoreArgument(argument)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
String parameterName = parameterNames != null && index < parameterNames.length |
||||||
|
? parameterNames[index] |
||||||
|
: "arg" + index; |
||||||
|
argumentNode.set(parameterName, convertArgument(argument)); |
||||||
|
} |
||||||
|
|
||||||
|
if (argumentNode.isEmpty()) { |
||||||
|
rootNode.remove("arguments"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Servlet、校验结果和流对象不可序列化,也不属于业务请求参数,统一跳过。 |
||||||
|
*/ |
||||||
|
private boolean shouldIgnoreArgument(Object argument) { |
||||||
|
return argument instanceof ServletRequest |
||||||
|
|| argument instanceof ServletResponse |
||||||
|
|| argument instanceof BindingResult |
||||||
|
|| argument instanceof InputStream |
||||||
|
|| argument instanceof OutputStream |
||||||
|
|| argument instanceof Reader |
||||||
|
|| argument instanceof Writer; |
||||||
|
} |
||||||
|
|
||||||
|
private JsonNode convertArgument(Object argument) { |
||||||
|
if (argument == null) { |
||||||
|
return objectMapper.getNodeFactory().nullNode(); |
||||||
|
} |
||||||
|
if (argument instanceof MultipartFile file) { |
||||||
|
return buildFileNode(file); |
||||||
|
} |
||||||
|
if (argument instanceof byte[] bytes) { |
||||||
|
return objectMapper.getNodeFactory().textNode("[二进制内容已忽略,大小:" + bytes.length + "字节]"); |
||||||
|
} |
||||||
|
if (argument.getClass().isArray()) { |
||||||
|
ArrayNode arrayNode = objectMapper.createArrayNode(); |
||||||
|
int length = Array.getLength(argument); |
||||||
|
for (int index = 0; index < length; index++) { |
||||||
|
arrayNode.add(convertArgument(Array.get(argument, index))); |
||||||
|
} |
||||||
|
return arrayNode; |
||||||
|
} |
||||||
|
if (argument instanceof Iterable<?> iterable) { |
||||||
|
ArrayNode arrayNode = objectMapper.createArrayNode(); |
||||||
|
for (Object item : iterable) { |
||||||
|
arrayNode.add(convertArgument(item)); |
||||||
|
} |
||||||
|
return arrayNode; |
||||||
|
} |
||||||
|
if (argument instanceof Map<?, ?> map) { |
||||||
|
ObjectNode mapNode = objectMapper.createObjectNode(); |
||||||
|
map.forEach((key, value) -> mapNode.set(String.valueOf(key), convertArgument(value))); |
||||||
|
return mapNode; |
||||||
|
} |
||||||
|
return objectMapper.valueToTree(argument); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 上传文件只记录元信息,禁止读取或序列化文件内容。 |
||||||
|
*/ |
||||||
|
private ObjectNode buildFileNode(MultipartFile file) { |
||||||
|
ObjectNode fileNode = objectMapper.createObjectNode(); |
||||||
|
fileNode.put("fieldName", file.getName()); |
||||||
|
fileNode.put("originalFilename", file.getOriginalFilename()); |
||||||
|
fileNode.put("size", file.getSize()); |
||||||
|
fileNode.put("contentType", file.getContentType()); |
||||||
|
return fileNode; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 递归处理对象、数组和嵌套 JSON 字符串,凭证字段统一替换为固定掩码。 |
||||||
|
*/ |
||||||
|
private JsonNode sanitizeNode(JsonNode node) { |
||||||
|
if (node == null) { |
||||||
|
return objectMapper.getNodeFactory().nullNode(); |
||||||
|
} |
||||||
|
if (node.isObject()) { |
||||||
|
ObjectNode objectNode = (ObjectNode) node; |
||||||
|
List<String> fieldNames = new ArrayList<>(); |
||||||
|
objectNode.fieldNames().forEachRemaining(fieldNames::add); |
||||||
|
for (String fieldName : fieldNames) { |
||||||
|
if (isCredentialField(fieldName)) { |
||||||
|
objectNode.put(fieldName, MASK_VALUE); |
||||||
|
} else { |
||||||
|
objectNode.set(fieldName, sanitizeNode(objectNode.get(fieldName))); |
||||||
|
} |
||||||
|
} |
||||||
|
return objectNode; |
||||||
|
} else if (node.isArray()) { |
||||||
|
ArrayNode arrayNode = (ArrayNode) node; |
||||||
|
for (int index = 0; index < arrayNode.size(); index++) { |
||||||
|
arrayNode.set(index, sanitizeNode(arrayNode.get(index))); |
||||||
|
} |
||||||
|
return arrayNode; |
||||||
|
} else if (node.isTextual()) { |
||||||
|
return sanitizeJsonText(node); |
||||||
|
} |
||||||
|
return node; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* APP 转发参数中的 body 可能是 JSON 字符串,需要再次解析后脱敏,避免内部密码被遗漏。 |
||||||
|
*/ |
||||||
|
private JsonNode sanitizeJsonText(JsonNode valueNode) { |
||||||
|
String value = valueNode.textValue(); |
||||||
|
if (value == null) { |
||||||
|
return valueNode; |
||||||
|
} |
||||||
|
String trimmedValue = value.trim(); |
||||||
|
if (!(trimmedValue.startsWith("{") || trimmedValue.startsWith("["))) { |
||||||
|
return valueNode; |
||||||
|
} |
||||||
|
try { |
||||||
|
JsonNode nestedNode = objectMapper.readTree(trimmedValue); |
||||||
|
JsonNode sanitizedNode = sanitizeNode(nestedNode); |
||||||
|
return objectMapper.getNodeFactory().textNode(objectMapper.writeValueAsString(sanitizedNode)); |
||||||
|
} catch (Exception ignored) { |
||||||
|
// 普通字符串可能恰好以大括号开头,解析失败时保留原值即可。
|
||||||
|
return valueNode; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private boolean isCredentialField(String fieldName) { |
||||||
|
String normalizedName = fieldName.toLowerCase(Locale.ROOT).replace("_", "").replace("-", ""); |
||||||
|
return normalizedName.contains("password") |
||||||
|
|| normalizedName.contains("passwd") |
||||||
|
|| normalizedName.contains("pwd") |
||||||
|
|| normalizedName.contains("token") |
||||||
|
|| normalizedName.contains("authorization") |
||||||
|
|| normalizedName.contains("cookie") |
||||||
|
|| normalizedName.contains("secret") |
||||||
|
|| normalizedName.contains("signeddata"); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 日志写入属于附加能力,任何日志异常都不能改变原业务接口的返回或异常。 |
||||||
|
*/ |
||||||
|
private void saveLogSafely(HttpServletRequest request, |
||||||
|
HttpServletResponse response, |
||||||
|
UserAuth currentUser, |
||||||
|
String requestParams, |
||||||
|
Object result, |
||||||
|
Throwable throwable, |
||||||
|
long executionTime, |
||||||
|
LocalDateTime logTime) { |
||||||
|
if (request == null) { |
||||||
|
return; |
||||||
|
} |
||||||
|
try { |
||||||
|
int status = resolveStatus(response, result, throwable); |
||||||
|
SystemLog systemLog = new SystemLog(); |
||||||
|
if (currentUser != null) { |
||||||
|
systemLog.setUserId(currentUser.getUserId()); |
||||||
|
systemLog.setUserName(currentUser.getUserName()); |
||||||
|
systemLog.setNickName(currentUser.getNickName()); |
||||||
|
} |
||||||
|
systemLog.setRequestUri(request.getRequestURI()); |
||||||
|
systemLog.setRequestMethod(request.getMethod()); |
||||||
|
systemLog.setRequestParams(requestParams); |
||||||
|
systemLog.setSourceIp(resolveSourceIp(request)); |
||||||
|
systemLog.setExecutionTime(executionTime); |
||||||
|
systemLog.setStatus(status); |
||||||
|
systemLog.setErrorMessage(resolveErrorMessage(response, result, throwable, status)); |
||||||
|
systemLog.setLogTime(logTime); |
||||||
|
systemLogService.save(systemLog); |
||||||
|
} catch (Exception ex) { |
||||||
|
log.error("系统访问日志保存失败:{}", ex.getMessage(), ex); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private int resolveStatus(HttpServletResponse response, Object result, Throwable throwable) { |
||||||
|
if (throwable != null) { |
||||||
|
return FAILURE; |
||||||
|
} |
||||||
|
if (response != null && response.getStatus() >= HttpServletResponse.SC_BAD_REQUEST) { |
||||||
|
return FAILURE; |
||||||
|
} |
||||||
|
if (result instanceof Result<?> apiResult && apiResult.getCode() != HttpServletResponse.SC_OK) { |
||||||
|
return FAILURE; |
||||||
|
} |
||||||
|
return SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
private String resolveErrorMessage(HttpServletResponse response, |
||||||
|
Object result, |
||||||
|
Throwable throwable, |
||||||
|
int status) { |
||||||
|
if (status == SUCCESS) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
String message = null; |
||||||
|
if (throwable != null) { |
||||||
|
message = throwable.getMessage(); |
||||||
|
if (message == null || message.isBlank()) { |
||||||
|
message = throwable.getClass().getSimpleName(); |
||||||
|
} |
||||||
|
} else if (result instanceof Result<?> apiResult) { |
||||||
|
message = apiResult.getMessage(); |
||||||
|
} |
||||||
|
if ((message == null || message.isBlank()) && response != null) { |
||||||
|
message = "HTTP " + response.getStatus(); |
||||||
|
} |
||||||
|
return truncate(message, MAX_ERROR_LENGTH, TRUNCATED_MARK); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 兼容常见反向代理,X-Forwarded-For 存在多级地址时取第一个有效值。 |
||||||
|
*/ |
||||||
|
private String resolveSourceIp(HttpServletRequest request) { |
||||||
|
String forwardedFor = request.getHeader("X-Forwarded-For"); |
||||||
|
if (isValidIpHeader(forwardedFor)) { |
||||||
|
for (String ip : forwardedFor.split(",")) { |
||||||
|
if (isValidIpHeader(ip)) { |
||||||
|
return ip.trim(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
String realIp = request.getHeader("X-Real-IP"); |
||||||
|
if (isValidIpHeader(realIp)) { |
||||||
|
return realIp.trim(); |
||||||
|
} |
||||||
|
return request.getRemoteAddr(); |
||||||
|
} |
||||||
|
|
||||||
|
private boolean isValidIpHeader(String value) { |
||||||
|
return value != null && !value.isBlank() && !"unknown".equalsIgnoreCase(value.trim()); |
||||||
|
} |
||||||
|
|
||||||
|
private String truncate(String value, int maxLength, String mark) { |
||||||
|
if (value == null || value.length() <= maxLength) { |
||||||
|
return value; |
||||||
|
} |
||||||
|
int contentLength = Math.max(0, maxLength - mark.length()); |
||||||
|
return value.substring(0, contentLength) + mark; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
package com.biutag.supervision.controller.system; |
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
||||||
|
import com.biutag.supervision.pojo.Result; |
||||||
|
import com.biutag.supervision.pojo.entity.SystemLog; |
||||||
|
import com.biutag.supervision.pojo.param.SystemLogQueryParam; |
||||||
|
import com.biutag.supervision.service.SystemLogService; |
||||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||||
|
import lombok.RequiredArgsConstructor; |
||||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||||
|
import org.springframework.web.bind.annotation.RestController; |
||||||
|
|
||||||
|
/** |
||||||
|
* 系统访问日志查询接口。 |
||||||
|
*/ |
||||||
|
@Tag(name = "系统日志") |
||||||
|
@RequiredArgsConstructor |
||||||
|
@RequestMapping("systemLog") |
||||||
|
@RestController |
||||||
|
public class SystemLogController { |
||||||
|
|
||||||
|
private final SystemLogService systemLogService; |
||||||
|
|
||||||
|
/** |
||||||
|
* 分页查询系统访问日志。 |
||||||
|
*/ |
||||||
|
@Operation(summary = "分页查询系统日志") |
||||||
|
@GetMapping |
||||||
|
public Result<Page<SystemLog>> list(SystemLogQueryParam queryParam) { |
||||||
|
return Result.success(systemLogService.pageByQuery(queryParam)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
package com.biutag.supervision.job; |
||||||
|
|
||||||
|
import com.biutag.supervision.service.SystemLogService; |
||||||
|
import lombok.RequiredArgsConstructor; |
||||||
|
import lombok.extern.slf4j.Slf4j; |
||||||
|
import org.springframework.stereotype.Component; |
||||||
|
|
||||||
|
import java.time.LocalDateTime; |
||||||
|
|
||||||
|
/** |
||||||
|
* 系统访问日志定时清理任务,只保留最近四个月的数据。 |
||||||
|
*/ |
||||||
|
@Slf4j |
||||||
|
@Component |
||||||
|
@RequiredArgsConstructor |
||||||
|
public class SystemLogCleanJob { |
||||||
|
|
||||||
|
private static final int RETENTION_MONTHS = 4; |
||||||
|
|
||||||
|
private final SystemLogService systemLogService; |
||||||
|
|
||||||
|
/** |
||||||
|
* 清理四个月以前的日志。 |
||||||
|
* 当前处于上线观察期,未注册定时调度;确认启用后再增加每天凌晨 03:30 的 @Scheduled 注解。 |
||||||
|
* 启用调度后,单次清理失败只记录错误信息,不影响后续执行。 |
||||||
|
* @Scheduled(cron = "0 30 3 * * ?") |
||||||
|
*/ |
||||||
|
public void cleanExpiredLogs() { |
||||||
|
LocalDateTime expireTime = LocalDateTime.now().minusMonths(RETENTION_MONTHS); |
||||||
|
try { |
||||||
|
int removedCount = systemLogService.removeBefore(expireTime); |
||||||
|
log.info("系统访问日志清理完成,截止时间:{},删除数量:{}", expireTime, removedCount); |
||||||
|
} catch (RuntimeException ex) { |
||||||
|
log.error("系统访问日志清理失败,截止时间:{},失败原因:{}", expireTime, ex.getMessage(), ex); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
package com.biutag.supervision.mapper; |
||||||
|
|
||||||
|
import com.baomidou.dynamic.datasource.annotation.DS; |
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||||
|
import com.biutag.supervision.pojo.entity.SystemLog; |
||||||
|
|
||||||
|
/** |
||||||
|
* 系统访问日志数据访问接口。 |
||||||
|
*/ |
||||||
|
@DS("master") |
||||||
|
public interface SystemLogMapper extends BaseMapper<SystemLog> { |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
package com.biutag.supervision.pojo.entity; |
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType; |
||||||
|
import com.baomidou.mybatisplus.annotation.TableField; |
||||||
|
import com.baomidou.mybatisplus.annotation.TableId; |
||||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||||
|
import lombok.Getter; |
||||||
|
import lombok.Setter; |
||||||
|
|
||||||
|
import java.time.LocalDateTime; |
||||||
|
|
||||||
|
/** |
||||||
|
* 系统接口访问日志。 |
||||||
|
*/ |
||||||
|
@Schema(description = "系统接口访问日志") |
||||||
|
@TableName("system_log") |
||||||
|
@Getter |
||||||
|
@Setter |
||||||
|
public class SystemLog { |
||||||
|
|
||||||
|
@Schema(description = "日志主键") |
||||||
|
@TableId(value = "id", type = IdType.AUTO) |
||||||
|
private Long id; |
||||||
|
|
||||||
|
@Schema(description = "请求人用户ID") |
||||||
|
@TableField("user_id") |
||||||
|
private String userId; |
||||||
|
|
||||||
|
@Schema(description = "请求人登录名") |
||||||
|
@TableField("user_name") |
||||||
|
private String userName; |
||||||
|
|
||||||
|
@Schema(description = "请求人姓名") |
||||||
|
@TableField("nick_name") |
||||||
|
private String nickName; |
||||||
|
|
||||||
|
@Schema(description = "请求接口地址") |
||||||
|
@TableField("request_uri") |
||||||
|
private String requestUri; |
||||||
|
|
||||||
|
@Schema(description = "HTTP访问方式", example = "GET") |
||||||
|
@TableField("request_method") |
||||||
|
private String requestMethod; |
||||||
|
|
||||||
|
@Schema(description = "脱敏并截断后的请求参数,最多4000字符") |
||||||
|
@TableField("request_params") |
||||||
|
private String requestParams; |
||||||
|
|
||||||
|
@Schema(description = "请求来源IP") |
||||||
|
@TableField("source_ip") |
||||||
|
private String sourceIp; |
||||||
|
|
||||||
|
@Schema(description = "Controller调用链执行耗时,单位:毫秒") |
||||||
|
@TableField("execution_time") |
||||||
|
private Long executionTime; |
||||||
|
|
||||||
|
@Schema(description = "执行状态:1-成功,0-失败", allowableValues = {"0", "1"}, example = "1") |
||||||
|
@TableField("status") |
||||||
|
private Integer status; |
||||||
|
|
||||||
|
@Schema(description = "异常或失败摘要,最多1000字符") |
||||||
|
@TableField("error_message") |
||||||
|
private String errorMessage; |
||||||
|
|
||||||
|
@Schema(description = "请求开始时间") |
||||||
|
@TableField("log_time") |
||||||
|
private LocalDateTime logTime; |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
package com.biutag.supervision.pojo.param; |
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||||
|
import lombok.Getter; |
||||||
|
import lombok.Setter; |
||||||
|
import org.springframework.format.annotation.DateTimeFormat; |
||||||
|
|
||||||
|
import java.time.LocalDateTime; |
||||||
|
|
||||||
|
/** |
||||||
|
* 系统日志分页查询参数。 |
||||||
|
*/ |
||||||
|
@Schema(description = "系统日志分页查询参数") |
||||||
|
@Getter |
||||||
|
@Setter |
||||||
|
public class SystemLogQueryParam extends BasePage { |
||||||
|
|
||||||
|
@Schema(description = "日志类型:login-登录日志,operation-操作日志", allowableValues = {"login", "operation"}) |
||||||
|
private String logType; |
||||||
|
|
||||||
|
@Schema(description = "请求人登录名或姓名,支持模糊查询") |
||||||
|
private String requester; |
||||||
|
|
||||||
|
@Schema(description = "接口地址,支持模糊查询") |
||||||
|
private String requestUri; |
||||||
|
|
||||||
|
@Schema(description = "HTTP访问方式", example = "GET") |
||||||
|
private String requestMethod; |
||||||
|
|
||||||
|
@Schema(description = "来源IP,支持模糊查询") |
||||||
|
private String sourceIp; |
||||||
|
|
||||||
|
@Schema(description = "执行状态:1-成功,0-失败", allowableValues = {"0", "1"}) |
||||||
|
private Integer status; |
||||||
|
|
||||||
|
@Schema(description = "日志开始时间,格式:yyyy-MM-dd HH:mm:ss") |
||||||
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
||||||
|
private LocalDateTime startTime; |
||||||
|
|
||||||
|
@Schema(description = "日志结束时间,格式:yyyy-MM-dd HH:mm:ss") |
||||||
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
||||||
|
private LocalDateTime endTime; |
||||||
|
} |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
package com.biutag.supervision.service; |
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil; |
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
||||||
|
import com.biutag.supervision.mapper.SystemLogMapper; |
||||||
|
import com.biutag.supervision.pojo.entity.SystemLog; |
||||||
|
import com.biutag.supervision.pojo.param.SystemLogQueryParam; |
||||||
|
import org.springframework.stereotype.Service; |
||||||
|
|
||||||
|
import java.time.LocalDateTime; |
||||||
|
import java.util.Objects; |
||||||
|
import java.util.Set; |
||||||
|
|
||||||
|
/** |
||||||
|
* 系统访问日志服务。 |
||||||
|
*/ |
||||||
|
@Service |
||||||
|
public class SystemLogService extends ServiceImpl<SystemLogMapper, SystemLog> { |
||||||
|
|
||||||
|
private static final String LOG_TYPE_LOGIN = "login"; |
||||||
|
private static final String LOG_TYPE_OPERATION = "operation"; |
||||||
|
|
||||||
|
/** 实际完成登录动作并创建登录态的接口。 */ |
||||||
|
private static final Set<String> LOGIN_REQUEST_URIS = Set.of( |
||||||
|
"/login", |
||||||
|
"/jit/p7certAuth", |
||||||
|
"/app/login" |
||||||
|
); |
||||||
|
|
||||||
|
/** |
||||||
|
* 按页面查询条件检索系统访问日志。 |
||||||
|
* |
||||||
|
* @param queryParam 分页及筛选条件 |
||||||
|
* @return 日志分页数据 |
||||||
|
*/ |
||||||
|
public Page<SystemLog> pageByQuery(SystemLogQueryParam queryParam) { |
||||||
|
LambdaQueryWrapper<SystemLog> queryWrapper = new LambdaQueryWrapper<>(); |
||||||
|
|
||||||
|
if (StrUtil.isNotBlank(queryParam.getRequester())) { |
||||||
|
queryWrapper.and(wrapper -> wrapper |
||||||
|
.like(SystemLog::getUserName, queryParam.getRequester()) |
||||||
|
.or() |
||||||
|
.like(SystemLog::getNickName, queryParam.getRequester())); |
||||||
|
} |
||||||
|
|
||||||
|
// 登录日志和操作日志使用同一张表,通过登录接口白名单进行互斥分类。
|
||||||
|
if (LOG_TYPE_LOGIN.equals(queryParam.getLogType())) { |
||||||
|
queryWrapper.in(SystemLog::getRequestUri, LOGIN_REQUEST_URIS); |
||||||
|
} else if (LOG_TYPE_OPERATION.equals(queryParam.getLogType())) { |
||||||
|
queryWrapper.notIn(SystemLog::getRequestUri, LOGIN_REQUEST_URIS); |
||||||
|
} |
||||||
|
|
||||||
|
queryWrapper |
||||||
|
.like(StrUtil.isNotBlank(queryParam.getRequestUri()), SystemLog::getRequestUri, queryParam.getRequestUri()) |
||||||
|
.eq(StrUtil.isNotBlank(queryParam.getRequestMethod()), SystemLog::getRequestMethod, queryParam.getRequestMethod()) |
||||||
|
.like(StrUtil.isNotBlank(queryParam.getSourceIp()), SystemLog::getSourceIp, queryParam.getSourceIp()) |
||||||
|
.eq(Objects.nonNull(queryParam.getStatus()), SystemLog::getStatus, queryParam.getStatus()) |
||||||
|
.ge(Objects.nonNull(queryParam.getStartTime()), SystemLog::getLogTime, queryParam.getStartTime()) |
||||||
|
.le(Objects.nonNull(queryParam.getEndTime()), SystemLog::getLogTime, queryParam.getEndTime()) |
||||||
|
.orderByDesc(SystemLog::getLogTime) |
||||||
|
.orderByDesc(SystemLog::getId); |
||||||
|
|
||||||
|
return page(Page.of(queryParam.getCurrent(), queryParam.getSize()), queryWrapper); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 删除截止时间之前的系统访问日志。 |
||||||
|
* |
||||||
|
* @param expireTime 日志保留截止时间 |
||||||
|
* @return 实际删除条数 |
||||||
|
*/ |
||||||
|
public int removeBefore(LocalDateTime expireTime) { |
||||||
|
return baseMapper.delete(new LambdaQueryWrapper<SystemLog>() |
||||||
|
.lt(SystemLog::getLogTime, expireTime)); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue