1
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package com.yunhai.bschclassmanage.Repository;
|
||||
|
||||
import com.yunhai.bschclassmanage.entity.SysOperationLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public interface SysOperationLogRepository
|
||||
extends JpaRepository<SysOperationLog, Long>, JpaSpecificationExecutor<SysOperationLog> {
|
||||
|
||||
/** 按级别 + 截止时间批量删除(单条 DELETE,定时清理用) */
|
||||
long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime time);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.yunhai.bschclassmanage.aspect;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.bschclassmanage.entity.SysOperationLog;
|
||||
import com.yunhai.bschclassmanage.service.ISysOperationLogService;
|
||||
import com.yunhai.bschclassmanage.utils.GzipUtil;
|
||||
import com.yunhai.bschclassmanage.utils.OperationLogResolver;
|
||||
import com.yunhai.bschclassmanage.utils.OtherUtil;
|
||||
import com.yunhai.bschclassmanage.utils.SensitiveDataUtil;
|
||||
import com.yunhai.bschclassmanage.utils.UserContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 系统审计日志切面(双轨之审计轨)
|
||||
* - 全量拦截 controller 包所有方法,不依赖注解
|
||||
* - 排除日志查询控制器自身(防递归)
|
||||
* - 与业务日志切面 OperationLogAspect(Order 2) 物理隔离,互不影响
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(3)
|
||||
public class SysOperationLogAspect {
|
||||
|
||||
/** 排除的控制器(日志查询自身,防递归刷屏) */
|
||||
private static final List<String> EXCLUDE_CONTROLLERS = Arrays.asList(
|
||||
"com.yunhai.bschclassmanage.controller.api.LogController", // 业务日志查询
|
||||
"com.yunhai.bschclassmanage.controller.api.SysOperationLogController" // 审计日志查询
|
||||
);
|
||||
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
@Around("execution(* com.yunhai.bschclassmanage.controller..*.*(..))")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
// 1. 排除日志控制器
|
||||
String declType = point.getSignature().getDeclaringTypeName();
|
||||
if (EXCLUDE_CONTROLLERS.contains(declType)) {
|
||||
return point.proceed();
|
||||
}
|
||||
|
||||
HttpServletRequest request = getRequest();
|
||||
String httpMethod = request != null ? request.getMethod() : "";
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
SysOperationLog entity = buildEntity(point, request, httpMethod);
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = point.proceed();
|
||||
entity.setStatusCode(200);
|
||||
fillResponse(entity, result, DEFAULT_MAX_RESPONSE_SIZE);
|
||||
return result;
|
||||
} catch (Throwable e) {
|
||||
entity.setStatusCode(500);
|
||||
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||
throw e;
|
||||
} finally {
|
||||
entity.setCostTime(System.currentTimeMillis() - start);
|
||||
sysOperationLogService.saveAsync(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
|
||||
SysOperationLog e = new SysOperationLog();
|
||||
e.setCreateTime(LocalDateTime.now());
|
||||
|
||||
// 用户信息(来自 UserContext,由 ControllerUserContextAspect Order1 已填充)
|
||||
e.setUserId(UserContext.getUserId());
|
||||
e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous");
|
||||
e.setSchId(UserContext.getSchId());
|
||||
e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : OtherUtil.getClientIp());
|
||||
|
||||
String uri = request != null ? request.getRequestURI() : "";
|
||||
String methodName = point.getSignature().getName();
|
||||
e.setRequestUri(uri);
|
||||
e.setRequestMethod(httpMethod);
|
||||
e.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
|
||||
e.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, uri, methodName).getCode());
|
||||
e.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, uri, methodName));
|
||||
if (request != null) {
|
||||
e.setUserAgent(request.getHeader("User-Agent"));
|
||||
e.setRequestParams(SensitiveDataUtil.mask(buildRequestParams(point, request)));
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
|
||||
try {
|
||||
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
|
||||
String qs = request.getQueryString();
|
||||
if (StringUtils.hasText(qs)) return qs;
|
||||
}
|
||||
Object[] args = point.getArgs();
|
||||
if (args == null || args.length == 0) return "";
|
||||
List<Object> filtered = new ArrayList<>();
|
||||
for (Object arg : args) {
|
||||
if (arg == null) continue;
|
||||
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
||||
|| arg instanceof HttpSession) continue;
|
||||
filtered.add(arg);
|
||||
}
|
||||
return filtered.isEmpty() ? "" : JSON.toJSONString(filtered);
|
||||
} catch (Exception ex) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void fillResponse(SysOperationLog e, Object result, int max) {
|
||||
if (result == null) return;
|
||||
try {
|
||||
String resp = SensitiveDataUtil.mask(JSON.toJSONString(result));
|
||||
if (resp.length() > max) resp = resp.substring(0, max) + "...[截断]";
|
||||
e.setResponseData(GzipUtil.compress(resp));
|
||||
} catch (Exception ex) {
|
||||
log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return a != null ? a.getRequest() : null;
|
||||
}
|
||||
|
||||
private String truncate(String s, int max) {
|
||||
return s != null && s.length() > max ? s.substring(0, max) : s;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.yunhai.bschclassmanage.controller.api;
|
||||
|
||||
import com.yunhai.bschclassmanage.config.Config;
|
||||
import com.yunhai.bschclassmanage.controller.BaseController;
|
||||
import com.yunhai.bschclassmanage.entity.SysOperationLog;
|
||||
import com.yunhai.bschclassmanage.model.vo.PageVO;
|
||||
import com.yunhai.bschclassmanage.service.ISysOperationLogService;
|
||||
import com.yunhai.bschclassmanage.utils.UserContext;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping(Config.API_PATH + "/syslog/")
|
||||
@CrossOrigin(originPatterns = {"*"}, allowCredentials = "true")
|
||||
public class SysOperationLogController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
/** 审计日志分页查询(默认按当前用户 schId 隔离) */
|
||||
@GetMapping("list")
|
||||
public PageVO<SysOperationLog> list(
|
||||
@RequestParam(required = false) String uri,
|
||||
@RequestParam(required = false) String method,
|
||||
@RequestParam(required = false) Integer statusCode,
|
||||
@RequestParam(required = false) Integer logLevel,
|
||||
@RequestParam(required = false) Integer userId,
|
||||
Pageable pageable) {
|
||||
return sysOperationLogService.pageQuery(UserContext.getSchId(), uri, method,
|
||||
statusCode, logLevel, userId, pageable);
|
||||
}
|
||||
|
||||
/** 下载某条响应原文(GZIP 解压) */
|
||||
@GetMapping("download/{id}")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||
String data = sysOperationLogService.getResponseData(id);
|
||||
if (data == null) data = "该条审计日志未记录响应数据";
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
try {
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=" + new String(("audit-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
return ResponseEntity.ok().headers(headers)
|
||||
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||
.body(new InputStreamResource(bis));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.yunhai.bschclassmanage.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 系统审计日志实体(双轨之审计轨)。
|
||||
* 与业务日志实体 OperationLogRecord / 表 operation_log 物理隔离、互不融合。
|
||||
* 索引由 sqlscript/sys_operation_log.sql 负责创建,本实体不含 @Table(indexes),
|
||||
* 避免 ddl-auto=update 启动时重复建索引告警。
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sys_operation_log")
|
||||
public class SysOperationLog implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
|
||||
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
|
||||
private Integer logLevel;
|
||||
|
||||
@Column(name = "operation_type", nullable = false, length = 16)
|
||||
private String operationType; // DELETE/CREATE/UPDATE/QUERY
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Integer userId; // 来自 UserContext.getUserId()
|
||||
|
||||
@Column(name = "user_name", length = 64)
|
||||
private String userName; // 来自 UserContext.getUserName()
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId; // 多校隔离,来自 UserContext.getSchId()
|
||||
|
||||
@Column(name = "request_ip", nullable = false, length = 64)
|
||||
private String requestIp; // 客户端IP
|
||||
|
||||
@Column(name = "request_uri", nullable = false, length = 512)
|
||||
private String requestUri;
|
||||
|
||||
@Column(name = "request_method", nullable = false, length = 16)
|
||||
private String requestMethod;
|
||||
|
||||
@Column(name = "class_method", nullable = false, length = 512)
|
||||
private String classMethod;
|
||||
|
||||
@Column(name = "request_params", columnDefinition = "TEXT")
|
||||
private String requestParams; // 脱敏后
|
||||
|
||||
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||
private byte[] responseData; // GZIP 压缩后的响应 JSON
|
||||
|
||||
@Column(name = "status_code", nullable = false)
|
||||
private Integer statusCode; // 200 成功 / 500 异常
|
||||
|
||||
@Column(name = "cost_time", nullable = false)
|
||||
private Long costTime; // ms
|
||||
|
||||
@Column(name = "user_agent", length = 512)
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "error_msg", columnDefinition = "TEXT")
|
||||
private String errorMsg; // 异常信息,截断 2000
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.bschclassmanage.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 审计日志级别:用于区分重要/次要/一般操作,配合保留期策略。
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum LogLevel {
|
||||
IMPORTANT(1, "重要(删除)"),
|
||||
SECONDARY(2, "次要(新增/编辑)"),
|
||||
GENERAL(3, "一般(查询)");
|
||||
|
||||
private final int code;
|
||||
private final String desc;
|
||||
|
||||
public static LogLevel fromCode(Integer code) {
|
||||
if (code == null) return SECONDARY;
|
||||
for (LogLevel l : values()) if (l.code == code) return l;
|
||||
return SECONDARY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.bschclassmanage.model.tool;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户同步入参 DTO,结构与调用方实际下发的 JSON 保持一致。
|
||||
* 注意:该 JSON 是调用方自定义格式,并非 yunhai 框架的 BoaUser,
|
||||
* 因此用独立 DTO 承载,避免强转 / 反序列化失败。
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class SyncUserDTO {
|
||||
|
||||
private Integer id;
|
||||
private String account;
|
||||
private String name;
|
||||
private String idcard;
|
||||
private String phone;
|
||||
private String email;
|
||||
private Integer gender;
|
||||
private String birthday;
|
||||
private Integer status;
|
||||
private String profilePicture;
|
||||
private List<SyncUserGroupDTO> groups;
|
||||
private List<SyncUserRoleDTO> roleList;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yunhai.bschclassmanage.model.tool;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 同步用户中的班级/分组节点,对应 JSON 的 groups 元素。
|
||||
* 例:{"groupId":12,"no":null,"schoolId":3}
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class SyncUserGroupDTO {
|
||||
|
||||
private Integer groupId;
|
||||
private Integer no;
|
||||
private Integer schoolId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yunhai.bschclassmanage.model.tool;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 同步用户中的角色节点,对应 JSON 的 roleList 元素。
|
||||
* 例:{"roleId":5,"schId":3}
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class SyncUserRoleDTO {
|
||||
|
||||
private Integer roleId;
|
||||
private Integer schId;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yunhai.bschclassmanage.scheduler;
|
||||
|
||||
import com.yunhai.bschclassmanage.service.ISysOperationLogService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SysOperationLogCleanupTask {
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
@Scheduled(cron = "0 30 3 * * ?") // 每日 03:30(与业务零点任务错峰)
|
||||
public void run() {
|
||||
try {
|
||||
long deleted = sysOperationLogService.cleanupExpired();
|
||||
log.info("审计日志定时清理完成, 删除{}条", deleted);
|
||||
} catch (Exception e) {
|
||||
log.error("审计日志定时清理失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.bschclassmanage.service;
|
||||
|
||||
import com.yunhai.bschclassmanage.entity.SysOperationLog;
|
||||
import com.yunhai.bschclassmanage.model.vo.PageVO;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public interface ISysOperationLogService {
|
||||
/** 切面 finally 调用,@Async("auditLogExecutor") + REQUIRES_NEW,失败仅记日志 */
|
||||
void saveAsync(SysOperationLog entity);
|
||||
|
||||
/** 分页查询(不含 response_data 投影;实体直查,初版简单先行) */
|
||||
PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method,
|
||||
Integer statusCode, Integer logLevel,
|
||||
Integer userId, Pageable pageable);
|
||||
|
||||
/** 取单条(含 response_data)用于下载 */
|
||||
SysOperationLog getById(Long id);
|
||||
|
||||
/** 解压某条响应原文 */
|
||||
String getResponseData(Long id);
|
||||
|
||||
/** 定时清理,返回删除条数 */
|
||||
long cleanupExpired();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.yunhai.bschclassmanage.service.impl;
|
||||
|
||||
import com.yunhai.bschclassmanage.Repository.SysOperationLogRepository;
|
||||
import com.yunhai.bschclassmanage.entity.SysOperationLog;
|
||||
import com.yunhai.bschclassmanage.model.vo.PageVO;
|
||||
import com.yunhai.bschclassmanage.service.ISysOperationLogService;
|
||||
import com.yunhai.bschclassmanage.utils.GzipUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SysOperationLogServiceImpl implements ISysOperationLogService {
|
||||
|
||||
private static final int GENERAL_RETENTION_MONTHS = 12; // 一般(查询) 12 个月
|
||||
private static final int IMPORTANT_RETENTION_MONTHS = 36; // 重要/次要 36 个月
|
||||
|
||||
@Autowired
|
||||
private SysOperationLogRepository repository;
|
||||
|
||||
@Async("auditLogExecutor")
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void saveAsync(SysOperationLog entity) {
|
||||
try {
|
||||
repository.save(entity);
|
||||
} catch (Exception e) {
|
||||
log.error("审计日志异步落库失败, uri={}, method={}",
|
||||
entity.getRequestUri(), entity.getRequestMethod(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method,
|
||||
Integer statusCode, Integer logLevel,
|
||||
Integer userId, Pageable pageable) {
|
||||
Specification<SysOperationLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> ps = new ArrayList<>();
|
||||
if (schId != null) ps.add(cb.equal(root.get("schId").as(Integer.class), schId));
|
||||
if (StringUtils.isNotBlank(uri)) ps.add(cb.like(root.get("requestUri").as(String.class), "%" + uri + "%"));
|
||||
if (StringUtils.isNotBlank(method)) ps.add(cb.equal(root.get("requestMethod").as(String.class), method.toUpperCase()));
|
||||
if (statusCode != null) ps.add(cb.equal(root.get("statusCode").as(Integer.class), statusCode));
|
||||
if (logLevel != null) ps.add(cb.equal(root.get("logLevel").as(Integer.class), logLevel));
|
||||
if (userId != null) ps.add(cb.equal(root.get("userId").as(Integer.class), userId));
|
||||
return cb.and(ps.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable page = pageable;
|
||||
if (!page.getSort().isSorted()) {
|
||||
page = PageRequest.of(page.getPageNumber(), page.getPageSize(),
|
||||
Sort.by(Sort.Direction.DESC, "createTime"));
|
||||
}
|
||||
Page<SysOperationLog> p = repository.findAll(spec, page);
|
||||
// 注意:response_data (MEDIUMBLOB) 会随实体加载,若列表性能敏感,可改用投影 VO 排除该字段
|
||||
return new PageVO<>(p.getNumber() + 1, p.getSize(), p.getTotalElements(), p.getContent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysOperationLog getById(Long id) {
|
||||
return repository.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResponseData(Long id) {
|
||||
SysOperationLog e = getById(id);
|
||||
return (e != null && e.getResponseData() != null) ? GzipUtil.decompress(e.getResponseData()) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public long cleanupExpired() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime generalDeadline = now.minusMonths(GENERAL_RETENTION_MONTHS);
|
||||
LocalDateTime importantDeadline = now.minusMonths(IMPORTANT_RETENTION_MONTHS);
|
||||
long deleted = repository.deleteByLogLevelAndCreateTimeBefore(3, generalDeadline)
|
||||
+ repository.deleteByLogLevelAndCreateTimeBefore(2, importantDeadline)
|
||||
+ repository.deleteByLogLevelAndCreateTimeBefore(1, importantDeadline);
|
||||
log.info("审计日志清理完成, 删除{}条(一般截止={}, 重要/次要截止={})",
|
||||
deleted, generalDeadline, importantDeadline);
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.yunhai.bschclassmanage.utils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* GZIP 压缩/解压,用于审计日志响应体(MEDIUMBLOB)存储。
|
||||
*/
|
||||
public class GzipUtil {
|
||||
private static final int BUFFER_SIZE = 4096;
|
||||
|
||||
public static byte[] compress(String data) {
|
||||
if (data == null) return null;
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
GZIPOutputStream gzip = new GZIPOutputStream(out)) {
|
||||
gzip.write(data.getBytes(StandardCharsets.UTF_8));
|
||||
gzip.finish();
|
||||
return out.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("GZIP 压缩失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String decompress(byte[] data) {
|
||||
if (data == null || data.length == 0) return null;
|
||||
try (ByteArrayInputStream in = new ByteArrayInputStream(data);
|
||||
GZIPInputStream gzip = new GZIPInputStream(in);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[BUFFER_SIZE];
|
||||
int n;
|
||||
while ((n = gzip.read(buf)) > 0) out.write(buf, 0, n);
|
||||
return out.toString(StandardCharsets.UTF_8.name());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("GZIP 解压失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.bschclassmanage.utils;
|
||||
|
||||
import com.yunhai.bschclassmanage.enums.LogLevel;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* 审计日志级别/类型解析:根据 HTTP 方法 + URI + 方法名推断。
|
||||
* 对齐本项目命名(save/import/upload 视为新增,delete/del/remove 视为删除)。
|
||||
*/
|
||||
public class OperationLogResolver {
|
||||
private static final String[] DELETE_KEYWORDS = {"delete", "del", "remove"};
|
||||
private static final String[] CREATE_KEYWORDS = {"add", "create", "insert", "save", "import", "upload"};
|
||||
|
||||
public static LogLevel resolveLevel(String httpMethod, String uri, String methodName) {
|
||||
if (StringUtils.isBlank(httpMethod)) return LogLevel.SECONDARY;
|
||||
String m = httpMethod.toUpperCase();
|
||||
if ("GET".equals(m)) return LogLevel.GENERAL;
|
||||
if ("DELETE".equals(m)) return LogLevel.IMPORTANT;
|
||||
if (containsAny(uri, methodName, DELETE_KEYWORDS)) return LogLevel.IMPORTANT;
|
||||
return LogLevel.SECONDARY;
|
||||
}
|
||||
|
||||
public static String resolveOperationType(String httpMethod, String uri, String methodName) {
|
||||
if (StringUtils.isBlank(httpMethod)) return "UPDATE";
|
||||
String m = httpMethod.toUpperCase();
|
||||
if ("GET".equals(m)) return "QUERY";
|
||||
if ("DELETE".equals(m)) return "DELETE";
|
||||
if (containsAny(uri, methodName, DELETE_KEYWORDS)) return "DELETE";
|
||||
if (containsAny(uri, methodName, CREATE_KEYWORDS)) return "CREATE";
|
||||
return "UPDATE";
|
||||
}
|
||||
|
||||
private static boolean containsAny(String uri, String methodName, String[] keywords) {
|
||||
String text = StringUtils.lowerCase(uri) + " " + StringUtils.lowerCase(methodName);
|
||||
for (String kw : keywords) if (StringUtils.contains(text, kw)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.bschclassmanage.utils;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 日志脱敏:对 request_params / response_data 中的敏感字段值掩码为 ***
|
||||
* 默认敏感字段:password,pwd,secret,token,appSecret,accessKey,idCard,phone,mobile,credential
|
||||
* 脱敏失败返回原文,绝不阻断主流程。
|
||||
*/
|
||||
public class SensitiveDataUtil {
|
||||
|
||||
private static volatile Set<String> SENSITIVE_KEYS = new HashSet<>(Arrays.asList(
|
||||
"password", "pwd", "pass", "secret", "token", "appsecret", "accesskey",
|
||||
"idcard", "phone", "mobile", "credential"
|
||||
));
|
||||
|
||||
private static volatile Pattern FIELD_PATTERN = buildPattern();
|
||||
|
||||
private static Pattern buildPattern() {
|
||||
return Pattern.compile(
|
||||
"(\"(?i:(" + String.join("|", SENSITIVE_KEYS) + "))\"\\s*:\\s*)\"?[^,}\\s\"]+\"?",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
public static void setSensitiveKeys(Set<String> keys) {
|
||||
if (keys != null && !keys.isEmpty()) {
|
||||
SENSITIVE_KEYS = new HashSet<>(keys);
|
||||
FIELD_PATTERN = buildPattern();
|
||||
}
|
||||
}
|
||||
|
||||
public static String mask(String json) {
|
||||
if (StringUtils.isBlank(json)) return json;
|
||||
try {
|
||||
Matcher m = FIELD_PATTERN.matcher(json);
|
||||
return m.replaceAll("$1\"***\"");
|
||||
} catch (Exception e) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user