# 操作日志方案 · bschclassmanage 双轨版 > 依据:《操作日志模块跨项目落地方案》(UserBase 审计流派)+ 本项目现状实测。 > 核心策略:**双轨物理隔离**。现有 `OperationLogRecord` / `operation_log` 业务日志轨**完全不动**;新建 `SysOperationLog` / `sys_operation_log` 系统审计日志轨,按参考方案独立落地。两轨各自独立实体、独立表、独立切面、独立服务、独立控制器,互不融合、互不污染。 --- ## 1. 双轨总览 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ HTTP 请求 │ └──────────────────────────────────┬──────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ ControllerUserContextAspect (Order 1, 现有, 不改) │ │ 填充 UserContext (userId/userName/schId/ip/type),finally clear │ └──────────────────────────────────┬──────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 轨一·业务日志:OperationLogAspect (Order 2, 现有, 不改) │ │ 触发:仅 @OperationLog(module, description) 注解方法 │ │ 能力:SpEL #{#param}/#{#result} + LogContextHolder 动态描述 │ │ 落库:operation_log → operator/module/content/schId │ │ 定位:用户可见的业务操作历史("把学生X从A班转到B班") │ └──────────────────────────────────┬──────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ 轨二·审计日志:SysOperationLogAspect (Order 3, 新增) │ │ 触发:execution(* ...controller..*.*(..)) 全量拦截所有 Controller │ │ 排除:LogController + SysOperationLogController(防递归) │ │ 能力:URI/方法/级别/类型/参数(脱敏)/响应(GZIP)/状态码/耗时/错误 │ │ 落库:sys_operation_log → 全量 HTTP 审计 │ │ 定位:运维/合规/排障("谁在何时调了什么接口、成没成、耗时多久") │ └──────────────────────────────────┬──────────────────────────────────┘ ▼ 目标业务方法 ``` ### 1.1 两轨差异对照 | 维度 | 轨一·业务日志(现有不动) | 轨二·审计日志(新建) | |---|---|---| | 实体 | `OperationLogRecord` | `SysOperationLog`(新建) | | 表 | `operation_log` | `sys_operation_log`(新建) | | 触发 | `@OperationLog` 注解,选择性 | 全量拦截所有 Controller,无注解依赖 | | 语义 | 富业务语义(SpEL + 动态描述) | 无语义,纯 HTTP 审计 | | 字段 | operator/module/content/schId/ip/operateTime | logLevel/operationType/uri/method/params/response(BLOB)/statusCode/costTime/errorMsg/userAgent + userId/userName/schId/ip | | 响应 | 不存 | GZIP 压缩存 MEDIUMBLOB | | 级别 | 无 | 三级(重要/次要/一般) | | 量级 | 小(仅写操作) | 大(含 GET 全量) | | 服务 | `ILogService`/`LogService` + 模块 `*LogService` | `ISysOperationLogService`/`SysOperationLogServiceImpl`(新建) | | 控制器 | `LogController` search/systemSearch | `SysOperationLogController` auditSearch/download(新建) | | 异步池 | `logExecutor`(现有) | `auditLogExecutor`(新建独立池,隔离防互相挤压) | | 清理 | 无(量小) | 定时清理(一般12月/重要36月) | ### 1.2 两轨协同关系 - **写操作**(标了 `@OperationLog` 的 POST/PUT/DELETE):两轨都记——`operation_log` 留业务语义,`sys_operation_log` 留审计记录,通过 `operateTime` + `userId` + `requestUri` 关联。 - **GET 查询**:只在 `sys_operation_log`(业务轨不触发,因无注解)。 - **未标注解的写操作**:只在 `sys_operation_log`(业务轨不触发)。 - 查询时各查各表,业务日志列表与审计日志列表物理隔离,互不干扰。 --- ## 2. 轨一:业务日志(现有,完全保留,零改动) 下列组件**全部保持现状,本次方案不修改**: | 组件 | 路径 | 说明 | |---|---|---| | 实体 | `entity/OperationLogRecord` | 表 `operation_log`,字段不变 | | 注解 | `annotation/OperationLog` | `module()` + `description()`(SpEL)不变 | | 切面 | `aspect/OperationLogAspect` | Order(2),`@Around("@annotation(operationLog))` 不变 | | 动态描述 | `utils/LogContextHolder` | 不变 | | 服务 | `service/ILogService` + `service/com/LogService` | `save`/`saveAllLogAsync`/`search`/`buildLogRecord` 不变 | | 模块日志 | `service/impl/{student,teacher,classes,teachs}/*LogService` | 富语义文案构建,不变 | | 仓储 | `Repository/OperationLogRecordRepository` | 不变 | | 控制器 | `controller/api/LogController` | `search`/`systemSearch` 不变 | | 常量 | `model/constant/LogModule` | 不变 | > 业务日志轨已稳定运行,本次只做"加法"——新建审计轨,不碰任何业务日志代码。 --- ## 3. 轨二:系统审计日志(新建) 包名 `com.yunhai.bschclassmanage.*`,与业务日志轨物理隔离。标注 ✅ 新增。 ### 3.1 ✅ 实体 `entity/SysOperationLog.java` ```java package com.yunhai.bschclassmanage.entity; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import javax.persistence.*; import java.io.Serializable; import java.time.LocalDateTime; @NoArgsConstructor @Data @Entity @Table(name = "sys_operation_log", indexes = { @Index(name = "idx_sol_create_time", columnList = "create_time"), @Index(name = "idx_sol_user_time", columnList = "user_id, create_time"), @Index(name = "idx_sol_sch_time", columnList = "sch_id, create_time"), @Index(name = "idx_sol_uri_time", columnList = "request_uri, create_time"), @Index(name = "idx_sol_level_time", columnList = "log_level, create_time") }) @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) public class SysOperationLog implements Serializable { private static final long serialVersionUID = 1L; @EqualsAndHashCode.Include @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; @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; } ``` > 相比参考方案,补了 `sch_id` 字段(本项目多校系统,审计日志也需按校隔离查询)和 `user_name`(直接存用户名,不靠 getRemoteUser,规避参考方案第 7.5 节坑)。 ### 3.2 ✅ 建表 DDL `sqlscript/sys_operation_log.sql` ```sql -- 系统审计日志表(双轨之审计轨,与业务日志表 operation_log 物理隔离) CREATE TABLE `sys_operation_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `log_level` TINYINT NOT NULL COMMENT '1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)', `operation_type` VARCHAR(16) NOT NULL COMMENT 'DELETE/CREATE/UPDATE/QUERY', `user_id` INT DEFAULT NULL COMMENT '操作人ID(来自UserContext)', `user_name` VARCHAR(64) DEFAULT NULL COMMENT '操作人姓名(来自UserContext)', `sch_id` INT DEFAULT NULL COMMENT '学校ID(多校隔离)', `request_ip` VARCHAR(64) NOT NULL COMMENT '客户端IP', `request_uri` VARCHAR(512) NOT NULL COMMENT '请求URI', `request_method` VARCHAR(16) NOT NULL COMMENT 'HTTP方法', `class_method` VARCHAR(512) NOT NULL COMMENT '类名.方法名', `request_params` TEXT COMMENT '请求参数(脱敏后)', `response_data` MEDIUMBLOB COMMENT 'GZIP压缩后的响应JSON', `status_code` INT NOT NULL COMMENT '200成功 500异常', `cost_time` BIGINT NOT NULL COMMENT '耗时ms', `user_agent` VARCHAR(512) DEFAULT NULL, `error_msg` TEXT COMMENT '异常信息(截断2000)', `create_time` DATETIME(3) NOT NULL COMMENT '记录时间', PRIMARY KEY (`id`), KEY `idx_sol_create_time` (`create_time`), KEY `idx_sol_user_time` (`user_id`, `create_time`), KEY `idx_sol_sch_time` (`sch_id`, `create_time`), KEY `idx_sol_uri_time` (`request_uri`, `create_time`), KEY `idx_sol_level_time` (`log_level`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统审计日志(HTTP接口级,全量记录)'; ``` > `ddl-auto: update` 会自动建表,但**不会建索引**。建议手动执行此 DDL 一次,索引齐全。之后 JPA 只管加列。 ### 3.3 ✅ 枚举 `enums/LogLevel.java` ```java 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; } } ``` ### 3.4 ✅ 解析器 `utils/OperationLogResolver.java` ```java package com.yunhai.bschclassmanage.utils; import com.yunhai.bschclassmanage.enums.LogLevel; import org.apache.commons.lang3.StringUtils; 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; } } ``` > CREATE 关键词补了 `save/import/upload`,对齐本项目命名(`saveSchBinSchsystem`、`importLogs`)。 ### 3.5 ✅ 工具类 `utils/GzipUtil.java` ```java package com.yunhai.bschclassmanage.utils; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.zip.*; 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 (IOException 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 (IOException e) { throw new RuntimeException("GZIP 解压失败", e); } } } ``` ### 3.6 ✅ 脱敏工具 `utils/SensitiveDataUtil.java` ```java 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 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 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; } } } ``` ### 3.7 ✅ 仓储 `Repository/SysOperationLogRepository.java` ```java 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, JpaSpecificationExecutor { /** 按级别 + 截止时间批量删除(单条 DELETE,定时清理用) */ long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime time); } ``` ### 3.8 ✅ 服务接口 `service/ISysOperationLogService.java` ```java 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,用 Specification 投影或直接查实体) */ PageVO 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(); } ``` ### 3.9 ✅ 服务实现 `service/impl/SysOperationLogServiceImpl.java` ```java 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 pageQuery(Integer schId, String uri, String method, Integer statusCode, Integer logLevel, Integer userId, Pageable pageable) { Specification spec = (root, cb, cq) -> { List ps = new ArrayList<>(); if (schId != null) ps.add(cb.equal(root.get("schId"), schId)); if (StringUtils.isNotBlank(uri)) ps.add(cb.like(root.get("requestUri"), "%" + uri + "%")); if (StringUtils.isNotBlank(method)) ps.add(cb.equal(root.get("requestMethod"), method.toUpperCase())); if (statusCode != null) ps.add(cb.equal(root.get("statusCode"), statusCode)); if (logLevel != null) ps.add(cb.equal(root.get("logLevel"), logLevel)); if (userId != null) ps.add(cb.equal(root.get("userId"), 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 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; } } ``` > **列表性能**:若审计日志量大、列表查询慢,可新增 `SysOperationLogListVo`(不含 `responseData`),用 CriteriaBuilder.construct 投影查询,参考业务日志轨的写法。初版先用实体直查,简单先行。 ### 3.10 ✅ 切面 `aspect/SysOperationLogAspect.java`(核心) ```java 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.*; 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.reflect.MethodSignature; 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; /** * 系统审计日志切面(双轨之审计轨) * - 全量拦截 controller 包所有方法,不依赖注解 * - 排除日志查询控制器自身(防递归) * - 与业务日志切面 OperationLogAspect(Order 2) 物理隔离,互不影响 */ @Slf4j @Aspect @Component @Order(3) public class SysOperationLogAspect { /** 排除的控制器(日志查询自身,防递归刷屏) */ private static final List 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 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; } } ``` > **与业务切面共存**:`OperationLogAspect` Order(2) 是 `@Around("@annotation(operationLog))`,只匹配标注方法;`SysOperationLogAspect` Order(3) 是 `execution(* ...controller..*)`,全量匹配。两者 pointcut 不同,互不干扰。标了 `@OperationLog` 的写操作会被两个切面都处理——业务切面记 `operation_log`(语义),审计切面记 `sys_operation_log`(审计),正是双轨所需。 ### 3.11 ✅ 控制器 `controller/api/SysOperationLogController.java` ```java 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.GzipUtil; 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 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 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)); } } ``` ### 3.12 ✅ 定时清理 `scheduler/SysOperationLogCleanupTask.java` ```java 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); } } } ``` ### 3.13 ✅ 异步池 `config/AsyncConfig.java`(新增独立池,不动现有 logExecutor) ```java package com.yunhai.bschclassmanage.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; import java.util.concurrent.ThreadPoolExecutor; @Configuration @EnableAsync public class AsyncConfig { /** 现有:业务日志异步池(保持不动) */ @Bean(name = "logExecutor") public Executor logExecutor() { ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(2); ex.setMaxPoolSize(5); ex.setQueueCapacity(100); ex.setThreadNamePrefix("log-"); ex.initialize(); return ex; } /** 新增:审计日志异步池(独立,避免与业务日志互相挤压;量大所以池更大) */ @Bean(name = "auditLogExecutor") public Executor auditLogExecutor() { ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(4); ex.setMaxPoolSize(16); ex.setQueueCapacity(2000); ex.setThreadNamePrefix("audit-log-"); ex.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); // 队列满丢弃,不拖垮主流程 ex.initialize(); return ex; } } ``` > 现有 `logExecutor` 原样保留(不碰业务日志轨),仅**新增** `auditLogExecutor`。审计量大,池参数更大 + 拒绝策略 DiscardPolicy。 --- ## 4. 复用的现有基础设施(不改) | 组件 | 复用方式 | |---|---| | `UserContext` | 审计切面直接读 `getUserId()/getUserName()/getSchId()/getIp()` | | `ControllerUserContextAspect` (Order 1) | 已在审计切面(Order 3)外层填充 UserContext,无需改 | | `OtherUtil.getClientIp()` | 审计切面 IP 兜底 | | `PageVO` | 审计查询返回值 | | `Config.API_PATH` | 审计控制器路径前缀 `/api/syslog/` | | `BaseController` | 审计控制器继承 | | fastjson / commons-lang3 | 已有依赖 | | `@EnableAsync @EnableScheduling` | 启动类已开启 | --- ## 5. 落地步骤 Checklist - [ ] 执行 `sqlscript/sys_operation_log.sql` 建表(含索引) - [ ] 新增 `entity/SysOperationLog.java`(3.1) - [ ] 新增 `enums/LogLevel.java`(3.3) - [ ] 新增 `utils/OperationLogResolver.java`(3.4) - [ ] 新增 `utils/GzipUtil.java`(3.5) - [ ] 新增 `utils/SensitiveDataUtil.java`(3.6) - [ ] 新增 `Repository/SysOperationLogRepository.java`(3.7) - [ ] 新增 `service/ISysOperationLogService.java` + `service/impl/SysOperationLogServiceImpl.java`(3.8、3.9) - [ ] 新增 `aspect/SysOperationLogAspect.java`(3.10) - [ ] 新增 `controller/api/SysOperationLogController.java`(3.11) - [ ] 新增 `scheduler/SysOperationLogCleanupTask.java`(3.12) - [ ] 改 `config/AsyncConfig.java`:新增 `auditLogExecutor`(保留现有 `logExecutor`)(3.13) - [ ] **不改动**:`OperationLogRecord`/`OperationLog`注解/`OperationLogAspect`/`LogService`/模块`*LogService`/`LogController`/`LogContextHolder`/`UserContextInterceptor` - [ ] 编译 + 启动,调一个 GET 接口 → 查 `sys_operation_log` 应有 level=3 记录 - [ ] 调一个标了 `@OperationLog` 的写操作 → 查 `operation_log`(业务语义)+ `sys_operation_log`(审计)各一条 - [ ] 验证 `/api/syslog/list` 分页 + `/api/syslog/download/{id}` 下载响应原文 - [ ] 验证 `/api/log/search`(业务日志)不受影响 --- ## 6. 风险与回滚 | 风险 | 应对 | |---|---| | 审计切面全量拦截,所有 Controller 性能开销 | AOP 开销亚毫秒级;落库异步+独立池+REQUIRES_NEW,不阻塞主流程;队列满 DiscardPolicy 丢弃 | | GET 全量记,`sys_operation_log` 膨胀快 | level=3 独立保留 12 个月;定时清理 03:30;高频只读接口可在切面加 URI 前缀白名单跳过响应保存 | | 审计响应 `response_data` MEDIUMBLOB 拖慢列表 | 列表查询若慢,改用投影 VO 排除该字段(见 3.9 注) | | 两切面拦截同一写方法,事务/上下文冲突 | 两切面 pointcut 不同、落不同表、各自异步独立事务,无共享状态;UserContext 由最外层 Order(1) 统一 clear,内层只读不 clear | | 脱敏正则误伤 | `SensitiveDataUtil.mask` 失败返回原文,不阻断;可配 `sensitive-fields` 调整 | **回滚**:审计轨全部为新增文件,删除新增的切面/服务/控制器/实体即可完全回滚,业务日志轨零影响。 --- ## 7. 与原"融合方案"的差异说明 本次按用户要求改为**双轨物理隔离**,相比上一版"一张表两用"方案: | 项 | 旧融合方案 | 新双轨方案 | |---|---|---| | `OperationLogRecord` | 扩展审计字段 | **完全不动** | | `operation_log` 表 | 加列 + 加索引 | **不动** | | `OperationLogAspect` | 改双轨切点 | **不动**(保持 `@annotation` 触发) | | `LogService.buildLogRecord` | 补 level/type | **不动** | | 审计数据落点 | 复用 `operation_log`(level 区分) | 新表 `sys_operation_log` | | 审计触发 | 复用业务切面 | 新切面 `SysOperationLogAspect` 全量拦截 | | 隔离性 | 同表,靠 level 过滤 | 物理隔离,彻底互不污染 | **双轨优势**:业务日志轨零风险(不动任何现有代码),审计轨独立演进(可单独调优、单独清理、单独扩容),两轨职责清晰——业务日志看"做了什么",审计日志看"调了什么"。