Files
UserclassService/work/操作日志落地方案.md
2026-08-14 12:25:06 +08:00

39 KiB
Raw Permalink Blame History

操作日志方案 · 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

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

-- 系统审计日志表(双轨之审计轨,与业务日志表 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

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

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

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

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;
        }
    }
}

3.7 ✅ 仓储 Repository/SysOperationLogRepository.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<SysOperationLog, Long>, JpaSpecificationExecutor<SysOperationLog> {

    /** 按级别 + 截止时间批量删除(单条 DELETE,定时清理用) */
    long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime time);
}

3.8 ✅ 服务接口 service/ISysOperationLogService.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<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();
}

3.9 ✅ 服务实现 service/impl/SysOperationLogServiceImpl.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<SysOperationLog> pageQuery(Integer schId, String uri, String method,
                                             Integer statusCode, Integer logLevel,
                                             Integer userId, Pageable pageable) {
        Specification<SysOperationLog> spec = (root, cb, cq) -> {
            List<Predicate> 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<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;
    }
}

列表性能:若审计日志量大、列表查询慢,可新增 SysOperationLogListVo(不含 responseData),用 CriteriaBuilder.construct 投影查询,参考业务日志轨的写法。初版先用实体直查,简单先行。

3.10 ✅ 切面 aspect/SysOperationLogAspect.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<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;
    }
}

与业务切面共存:OperationLogAspect Order(2) 是 @Around("@annotation(operationLog)),只匹配标注方法;SysOperationLogAspect Order(3) 是 execution(* ...controller..*),全量匹配。两者 pointcut 不同,互不干扰。标了 @OperationLog 的写操作会被两个切面都处理——业务切面记 operation_log(语义),审计切面记 sys_operation_log(审计),正是双轨所需。

3.11 ✅ 控制器 controller/api/SysOperationLogController.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<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));
    }
}

3.12 ✅ 定时清理 scheduler/SysOperationLogCleanupTask.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)

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 过滤 物理隔离,彻底互不污染

双轨优势:业务日志轨零风险(不动任何现有代码),审计轨独立演进(可单独调优、单独清理、单独扩容),两轨职责清晰——业务日志看"做了什么",审计日志看"调了什么"。