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

879 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 操作日志方案 · 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<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`
```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`
```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`
```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`(核心)
```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`
```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`
```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 过滤 | 物理隔离,彻底互不污染 |
**双轨优势**:业务日志轨零风险(不动任何现有代码),审计轨独立演进(可单独调优、单独清理、单独扩容),两轨职责清晰——业务日志看"做了什么",审计日志看"调了什么"。