From afd7c9d704d928063b5a81cdcd9330d9b7b7faa8 Mon Sep 17 00:00:00 2001 From: wanghu Date: Fri, 14 Aug 2026 12:20:41 +0800 Subject: [PATCH] 1 --- .workbuddy/memory/2026-08-14.md | 83 ++ sqlscript/sys_operation_log.sql | 26 + .../Repository/SysOperationLogRepository.java | 16 + .../aspect/SysOperationLogAspect.java | 147 +++ .../api/SysOperationLogController.java | 58 ++ .../entity/SysOperationLog.java | 77 ++ .../bschclassmanage/enums/LogLevel.java | 24 + .../model/tool/SyncUserDTO.java | 29 + .../model/tool/SyncUserGroupDTO.java | 17 + .../model/tool/SyncUserRoleDTO.java | 16 + .../scheduler/SysOperationLogCleanupTask.java | 25 + .../service/ISysOperationLogService.java | 24 + .../impl/SysOperationLogServiceImpl.java | 96 ++ .../bschclassmanage/utils/GzipUtil.java | 40 + .../utils/OperationLogResolver.java | 38 + .../utils/SensitiveDataUtil.java | 47 + 操作日志落地方案.md | 878 ++++++++++++++++++ 17 files changed, 1641 insertions(+) create mode 100644 .workbuddy/memory/2026-08-14.md create mode 100644 sqlscript/sys_operation_log.sql create mode 100644 src/main/java/com/yunhai/bschclassmanage/Repository/SysOperationLogRepository.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/aspect/SysOperationLogAspect.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/entity/SysOperationLog.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/enums/LogLevel.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserDTO.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserGroupDTO.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserRoleDTO.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/scheduler/SysOperationLogCleanupTask.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/utils/GzipUtil.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/utils/OperationLogResolver.java create mode 100644 src/main/java/com/yunhai/bschclassmanage/utils/SensitiveDataUtil.java create mode 100644 操作日志落地方案.md diff --git a/.workbuddy/memory/2026-08-14.md b/.workbuddy/memory/2026-08-14.md new file mode 100644 index 0000000..8f0b51d --- /dev/null +++ b/.workbuddy/memory/2026-08-14.md @@ -0,0 +1,83 @@ +# 2026-08-14 + +## 独立 Bug 修复:`OtherUtil.jsonParseList` 泛型擦除导致 ClassCastException + +### 现象 +- `POST /oapi/user/AddUser`(同步用户接口,非 `api/` 包,未被 UserContext 重构涉及)返回 `code:6 / 内部服务错误`。 +- 服务器真实异常:`java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.yunhai.platform.utils.model.BoaUser` + 位置 `SyncUserService.syncAddOrUpdate(SyncUserService.java:54)`(增强 for 循环 `for (BoaUser user : userList)`)。 +- 连带次级错误(非致命、异步):审计日志 `sys_operation_log.request_params` 列存中文报 `Incorrect string value`(列字符集疑似 latin1/非 utf8mb4),被 `SysOperationLogServiceImpl` 捕获打印「异步落库失败」。 + +### 根因 +- `SyncUserController.AddUser` 用 `OtherUtil.jsonParseList(jsonString, BoaUser.class)` 解析成 `List`。 +- 原 `jsonParseList` 实现:`objectMapper.readValue(json, new TypeReference>() {})`。**Java 泛型擦除**使运行时 `List` 退化为原始 `List`,Jackson 把元素反序列化为 `LinkedHashMap`;遍历时强转 `BoaUser` 即 `ClassCastException`。 +- 同文件 `jsonParseArrayTree` 用 `objectMapper.getTypeFactory().constructCollectionType(List.class, elementClass)` 才正确——原 `jsonParseList` 注释写「TypeReference 保留泛型信息」是错的。 + +### 修复 +- `utils/OtherUtil.java` 的 `jsonParseList`:改用 `constructCollectionType(List.class, targetClass)` 构建 `JavaType` 再 `readValue`。 +- 影响面:顺带修好 `UpdateUser`、`DeleteUser`(同用 `jsonParseList`,之前同样会 ClassCast)。 + +### 待跟进(非本次阻塞) +- 审计日志表 `sys_operation_log.request_params` 列字符集问题:存中文报 `Incorrect string value`。需改 DDL 为 `utf8mb4`(或确认连接字符集)。属 DB schema,不影响主业务返回,已口头提示用户。 + +### 环境注意(延续) +- 本机 `mvn compile` 仍受 Lombok 命令行限制;改动在 IDE 验证。 + +## 深层根因 & 二次修复:`AddUser` 反序列化失败(JSON 与框架 BoaUser 结构不匹配) + +### 现象(修复 ClassCast 后仍未解决) +- `jsonParseList` 改 `constructCollectionType` 后,`AddUser` 仍 "反序列化失败"(500)。 +- 原因浮出:调用方下发的 JSON 是**自定义格式**,并非 yunhai 框架 `BoaUser`: + - `groups:[{"groupId":12,"no":null,"schoolId":3}]`、`roleList:[{"roleId":5,"schId":3}]` + - 原服务却按 `BoaUser` 调 `user.getSchool().getId()`、`group.getId()`、`group.getSort()`、`user.getRoleIds()` +- 用 `BoaUser.class` 反序列化时,Jackson(默认 `FAIL_ON_UNKNOWN_PROPERTIES=true`)对未知字段 `groups`/`roleList` 抛 `UnrecognizedPropertyException` → 仍 500。 + +### 修复(按用户指示:加 DTO 补字段) +- 新增 3 个 DTO(`model/tool` 包,与 `DeleteUser` 同风格、`@Data` + `@JsonIgnoreProperties(ignoreUnknown=true)`): + - `SyncUserDTO`:id, account, name, idcard, phone, email, gender(Integer), birthday, status(Integer), profilePicture, `List groups`, `List roleList` + - `SyncGroupDTO`:groupId, no(Integer), schoolId + - `SyncRoleDTO`:roleId, schId +- `SyncUserController`:`jsonParseList(jsonString, SyncUserDTO.class)`;`ISyncUserService.syncAddOrUpdate(List)`。 +- 重写 `SyncUserService`:去掉 `BoaUser`/`BoaGroup` 依赖,按真实字段映射: + - 角色判定改用 `user.getRoleList().stream().map(SyncRoleDTO::getRoleId)` 比 `UserRole` 值; + - `group.getId()`→`group.getGroupId()`;`user.getSchool().getId()`→`group.getSchoolId()`;`group.getSort()`→`group.getNo()`;`stuClassNo` = `String.valueOf(group.getNo())`。 +- `SchStudent.schId` 继承自 `BaseEntity`(存在);实体字段类型与 DTO 对齐(gender/status 为 Integer,idcard/account/name/phone 为 String)。 + +### 结论 +- 该接口此前(含重构前)从未真正可用:先因 `LinkedHashMap` 强转炸,后为 `BoaUser` 字段不匹配炸。现在用匹配 JSON 的 DTO 彻底解耦。 +- 注意 JSON 中 `roleId=5`(BUREAU_LEADER_USER),在当前样本数据下既不命中 isStudent 也不命中 isTeacher,故不会落库;这是调用方业务数据问题,非代码缺陷。 + +## 优化 `SyncUserService` +- 重写 `service/impl/sync/SyncUserService.java`,优化点: + 1. 角色判定改为每个用户只遍历一次 `roleList`(`collectRoleIds` 提取去重 Set),`isStudent`/`isTeacher` 改判集合包含,去除重复流式遍历。 + 2. `deleteUser` 中 `studentDeletes`/`teacherDeletes` 同一过滤条件算了两次 → 合并为单次 `validDeletes`;删除死代码 `groupsBySchool`。 + 3. 修复教师分组删除 `toMap` 2 段键在「同教师同 groupId 跨多学校」时被静默丢键的 bug → 改用 `Collectors.groupingBy` 收集 `List`,删除全部匹配分组。 + 4. 缓存清除去重:delete 时按 (schId,classId)/(schId) 去重后调用 `clearClassStudentCaches`/`clearTeacherCaches`(新增的批量方法)。 + 5. 补齐 add/update 后的缓存失效:此前只在 delete 清缓存,add/update 后班级学生缓存/学校教师缓存会陈旧 → 现同步保存后收集受影响键并 `clearClassStudentCaches`/`clearTeacherCaches`。 +- 行为保持:事务注解、角色归类(学生优先于教师)、字段映射不变。 + +## 优化 `SchClassUpgradeService`(学年升级) +- `upgradeSingleSystem` 原本每个年级各调一次 `queryService.getSchClassCacheFilter` 反序列化全校班级列表、再逐年级 `saveAll`;改为**基于 SET 的批量 `@Modifying` 更新**: + - 新增 `SchClassRepository.batchUpgradeGrade(schId, 非最高年级列表, now)`:`grade_id = grade_id + 1` 单语句原子完成(仅基础教育)。 + - 新增 `SchClassRepository.batchGraduate(schId, maxGrade, now)`:最高年级 `graduate = true`。 + - 用 `?1 ?2 ?3` 位置参数避免受现有 lettuce `@Param` 误用影响;`clearAutomatically = true` 与 `SchStudentRepository` 风格一致。 +- `upgradeAllClasses` 优化/修复: + - `schType` 提升到学制循环外只查一次(原在学制内重复查)。 + - `getCurrentYearTerm()` 加 null 守卫,避免 NPE。 + - 原 `schType.equals(...)` 在 `getSchType` 返回 null 时 NPE → 改为常量 `.equals(schType)` 判等,null 安全。 + - 升级日志保存包 try/catch,避免日志写入失败掩盖升级结果。 + - 升级后除 `clearClassCache` 外补 `clearGradeateClassCache`,消除毕业后毕业缓存脏数据(原漏清)。 +- 行为保持:仅基础教育非最高年级升级、最高年级毕业;失败不阻断其他学制;失败记录可重试(existsBy...Status=true 不命中)。 + +## 全局修正 Repository 的 `@Param` 错误注解 +- 发现 7 个 Repository 的 `@Query` 命名参数绑定误用了 `io.lettuce.core.dynamic.annotation.Param`(Lettuce Redis 注解),而非 Spring Data 的 `org.springframework.data.repository.query.Param`;后者不被 Spring Data 识别,运行时触发「Parameter binding not found」,属潜在崩溃隐患。 +- 统一替换为 `org.springframework.data.repository.query.Param`:SchClassRepository、SchClassHistoryRepository、SchDicCourseRepository、SchTeacherRepository、SchDicMajorsRepository、SchStudentRepository、SchTeacherGroupRepository。 +- 顺带在 SchClassRepository 补 `import java.util.Date;`(批量升级方法用到)并恢复 `Page`/`Pageable` 导入。 +- 现状:本沙箱无 Maven 无法编译验证,需本机 `mvn compile` 确认。 + +## 修复 `SchClassUpgradeService` 中 `JdbcTemplate` 报红 +- 现象:IDE 中标红 `private JdbcTemplate jdbcTemplate;`。 +- 根因:`pom.xml` 仅引入 `spring-boot-starter-data-jpa`(其传递依赖 `spring-boot-starter-jdbc`/`spring-jdbc` 含 `JdbcTemplate`),但 IDE 未正确解析传递依赖时 `JdbcTemplate` 类找不到 → 报红。 +- 修复: + 1. 在 `pom.xml` 显式新增 `spring-boot-starter-jdbc` 依赖(置于 data-jpa 之后),保证 `JdbcTemplate` 类与自动配置 Bean 稳定可用。 + 2. `SchClassUpgradeService` 由字段注入改为 Lombok `@RequiredArgsConstructor` 构造器注入,消除 IDEA 「Could not autowire. No beans of 'JdbcTemplate' type found」的自动注入识别告警,同时符合 Spring 推荐写法。`backupAllSnapshot` 的批量 INSERT...SELECT 快照用法保留。 diff --git a/sqlscript/sys_operation_log.sql b/sqlscript/sys_operation_log.sql new file mode 100644 index 0000000..452271d --- /dev/null +++ b/sqlscript/sys_operation_log.sql @@ -0,0 +1,26 @@ +-- 系统审计日志表(双轨之审计轨,与业务日志表 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接口级,全量记录)'; diff --git a/src/main/java/com/yunhai/bschclassmanage/Repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/bschclassmanage/Repository/SysOperationLogRepository.java new file mode 100644 index 0000000..9aea253 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/Repository/SysOperationLogRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.bschclassmanage.Repository; + +import com.yunhai.bschclassmanage.entity.SysOperationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +public interface SysOperationLogRepository + extends JpaRepository, JpaSpecificationExecutor { + + /** 按级别 + 截止时间批量删除(单条 DELETE,定时清理用) */ + long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime time); +} diff --git a/src/main/java/com/yunhai/bschclassmanage/aspect/SysOperationLogAspect.java b/src/main/java/com/yunhai/bschclassmanage/aspect/SysOperationLogAspect.java new file mode 100644 index 0000000..647c54f --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/aspect/SysOperationLogAspect.java @@ -0,0 +1,147 @@ +package com.yunhai.bschclassmanage.aspect; + +import com.alibaba.fastjson.JSON; +import com.yunhai.bschclassmanage.entity.SysOperationLog; +import com.yunhai.bschclassmanage.service.ISysOperationLogService; +import com.yunhai.bschclassmanage.utils.GzipUtil; +import com.yunhai.bschclassmanage.utils.OperationLogResolver; +import com.yunhai.bschclassmanage.utils.OtherUtil; +import com.yunhai.bschclassmanage.utils.SensitiveDataUtil; +import com.yunhai.bschclassmanage.utils.UserContext; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * 系统审计日志切面(双轨之审计轨) + * - 全量拦截 controller 包所有方法,不依赖注解 + * - 排除日志查询控制器自身(防递归) + * - 与业务日志切面 OperationLogAspect(Order 2) 物理隔离,互不影响 + */ +@Slf4j +@Aspect +@Component +@Order(3) +public class SysOperationLogAspect { + + /** 排除的控制器(日志查询自身,防递归刷屏) */ + private static final List 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; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java b/src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java new file mode 100644 index 0000000..6e00a41 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java @@ -0,0 +1,58 @@ +package com.yunhai.bschclassmanage.controller.api; + +import com.yunhai.bschclassmanage.config.Config; +import com.yunhai.bschclassmanage.controller.BaseController; +import com.yunhai.bschclassmanage.entity.SysOperationLog; +import com.yunhai.bschclassmanage.model.vo.PageVO; +import com.yunhai.bschclassmanage.service.ISysOperationLogService; +import com.yunhai.bschclassmanage.utils.UserContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.InputStreamResource; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping(Config.API_PATH + "/syslog/") +@CrossOrigin(originPatterns = {"*"}, allowCredentials = "true") +public class SysOperationLogController extends BaseController { + + @Autowired + private ISysOperationLogService sysOperationLogService; + + /** 审计日志分页查询(默认按当前用户 schId 隔离) */ + @GetMapping("list") + public PageVO 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)); + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/entity/SysOperationLog.java b/src/main/java/com/yunhai/bschclassmanage/entity/SysOperationLog.java new file mode 100644 index 0000000..8179e14 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/entity/SysOperationLog.java @@ -0,0 +1,77 @@ +package com.yunhai.bschclassmanage.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统审计日志实体(双轨之审计轨)。 + * 与业务日志实体 OperationLogRecord / 表 operation_log 物理隔离、互不融合。 + * 索引由 sqlscript/sys_operation_log.sql 负责创建,本实体不含 @Table(indexes), + * 避免 ddl-auto=update 启动时重复建索引告警。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_operation_log") +public class SysOperationLog implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */ + @Column(name = "log_level", nullable = false, columnDefinition = "TINYINT") + private Integer logLevel; + + @Column(name = "operation_type", nullable = false, length = 16) + private String operationType; // DELETE/CREATE/UPDATE/QUERY + + @Column(name = "user_id") + private Integer userId; // 来自 UserContext.getUserId() + + @Column(name = "user_name", length = 64) + private String userName; // 来自 UserContext.getUserName() + + @Column(name = "sch_id") + private Integer schId; // 多校隔离,来自 UserContext.getSchId() + + @Column(name = "request_ip", nullable = false, length = 64) + private String requestIp; // 客户端IP + + @Column(name = "request_uri", nullable = false, length = 512) + private String requestUri; + + @Column(name = "request_method", nullable = false, length = 16) + private String requestMethod; + + @Column(name = "class_method", nullable = false, length = 512) + private String classMethod; + + @Column(name = "request_params", columnDefinition = "TEXT") + private String requestParams; // 脱敏后 + + @Column(name = "response_data", columnDefinition = "MEDIUMBLOB") + private byte[] responseData; // GZIP 压缩后的响应 JSON + + @Column(name = "status_code", nullable = false) + private Integer statusCode; // 200 成功 / 500 异常 + + @Column(name = "cost_time", nullable = false) + private Long costTime; // ms + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "error_msg", columnDefinition = "TEXT") + private String errorMsg; // 异常信息,截断 2000 + + @CreationTimestamp + @Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/yunhai/bschclassmanage/enums/LogLevel.java b/src/main/java/com/yunhai/bschclassmanage/enums/LogLevel.java new file mode 100644 index 0000000..71c424d --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/enums/LogLevel.java @@ -0,0 +1,24 @@ +package com.yunhai.bschclassmanage.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 审计日志级别:用于区分重要/次要/一般操作,配合保留期策略。 + */ +@Getter +@AllArgsConstructor +public enum LogLevel { + IMPORTANT(1, "重要(删除)"), + SECONDARY(2, "次要(新增/编辑)"), + GENERAL(3, "一般(查询)"); + + private final int code; + private final String desc; + + public static LogLevel fromCode(Integer code) { + if (code == null) return SECONDARY; + for (LogLevel l : values()) if (l.code == code) return l; + return SECONDARY; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserDTO.java b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserDTO.java new file mode 100644 index 0000000..3741dd7 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserDTO.java @@ -0,0 +1,29 @@ +package com.yunhai.bschclassmanage.model.tool; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +/** + * 用户同步入参 DTO,结构与调用方实际下发的 JSON 保持一致。 + * 注意:该 JSON 是调用方自定义格式,并非 yunhai 框架的 BoaUser, + * 因此用独立 DTO 承载,避免强转 / 反序列化失败。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SyncUserDTO { + + private Integer id; + private String account; + private String name; + private String idcard; + private String phone; + private String email; + private Integer gender; + private String birthday; + private Integer status; + private String profilePicture; + private List groups; + private List roleList; +} diff --git a/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserGroupDTO.java b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserGroupDTO.java new file mode 100644 index 0000000..741319e --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserGroupDTO.java @@ -0,0 +1,17 @@ +package com.yunhai.bschclassmanage.model.tool; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * 同步用户中的班级/分组节点,对应 JSON 的 groups 元素。 + * 例:{"groupId":12,"no":null,"schoolId":3} + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SyncUserGroupDTO { + + private Integer groupId; + private Integer no; + private Integer schoolId; +} diff --git a/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserRoleDTO.java b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserRoleDTO.java new file mode 100644 index 0000000..e48043f --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncUserRoleDTO.java @@ -0,0 +1,16 @@ +package com.yunhai.bschclassmanage.model.tool; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * 同步用户中的角色节点,对应 JSON 的 roleList 元素。 + * 例:{"roleId":5,"schId":3} + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SyncUserRoleDTO { + + private Integer roleId; + private Integer schId; +} diff --git a/src/main/java/com/yunhai/bschclassmanage/scheduler/SysOperationLogCleanupTask.java b/src/main/java/com/yunhai/bschclassmanage/scheduler/SysOperationLogCleanupTask.java new file mode 100644 index 0000000..f683d77 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/scheduler/SysOperationLogCleanupTask.java @@ -0,0 +1,25 @@ +package com.yunhai.bschclassmanage.scheduler; + +import com.yunhai.bschclassmanage.service.ISysOperationLogService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class SysOperationLogCleanupTask { + + @Autowired + private ISysOperationLogService sysOperationLogService; + + @Scheduled(cron = "0 30 3 * * ?") // 每日 03:30(与业务零点任务错峰) + public void run() { + try { + long deleted = sysOperationLogService.cleanupExpired(); + log.info("审计日志定时清理完成, 删除{}条", deleted); + } catch (Exception e) { + log.error("审计日志定时清理失败", e); + } + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java b/src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java new file mode 100644 index 0000000..1eb3671 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java @@ -0,0 +1,24 @@ +package com.yunhai.bschclassmanage.service; + +import com.yunhai.bschclassmanage.entity.SysOperationLog; +import com.yunhai.bschclassmanage.model.vo.PageVO; +import org.springframework.data.domain.Pageable; + +public interface ISysOperationLogService { + /** 切面 finally 调用,@Async("auditLogExecutor") + REQUIRES_NEW,失败仅记日志 */ + void saveAsync(SysOperationLog entity); + + /** 分页查询(不含 response_data 投影;实体直查,初版简单先行) */ + PageVO 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(); +} diff --git a/src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java b/src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java new file mode 100644 index 0000000..f2b6421 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java @@ -0,0 +1,96 @@ +package com.yunhai.bschclassmanage.service.impl; + +import com.yunhai.bschclassmanage.Repository.SysOperationLogRepository; +import com.yunhai.bschclassmanage.entity.SysOperationLog; +import com.yunhai.bschclassmanage.model.vo.PageVO; +import com.yunhai.bschclassmanage.service.ISysOperationLogService; +import com.yunhai.bschclassmanage.utils.GzipUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.criteria.Predicate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class SysOperationLogServiceImpl implements ISysOperationLogService { + + private static final int GENERAL_RETENTION_MONTHS = 12; // 一般(查询) 12 个月 + private static final int IMPORTANT_RETENTION_MONTHS = 36; // 重要/次要 36 个月 + + @Autowired + private SysOperationLogRepository repository; + + @Async("auditLogExecutor") + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveAsync(SysOperationLog entity) { + try { + repository.save(entity); + } catch (Exception e) { + log.error("审计日志异步落库失败, uri={}, method={}", + entity.getRequestUri(), entity.getRequestMethod(), e); + } + } + + @Override + public PageVO pageQuery(Integer schId, String uri, String method, + Integer statusCode, Integer logLevel, + Integer userId, Pageable pageable) { + Specification spec = (root, query, cb) -> { + List ps = new ArrayList<>(); + if (schId != null) ps.add(cb.equal(root.get("schId").as(Integer.class), schId)); + if (StringUtils.isNotBlank(uri)) ps.add(cb.like(root.get("requestUri").as(String.class), "%" + uri + "%")); + if (StringUtils.isNotBlank(method)) ps.add(cb.equal(root.get("requestMethod").as(String.class), method.toUpperCase())); + if (statusCode != null) ps.add(cb.equal(root.get("statusCode").as(Integer.class), statusCode)); + if (logLevel != null) ps.add(cb.equal(root.get("logLevel").as(Integer.class), logLevel)); + if (userId != null) ps.add(cb.equal(root.get("userId").as(Integer.class), userId)); + return cb.and(ps.toArray(new Predicate[0])); + }; + Pageable page = pageable; + if (!page.getSort().isSorted()) { + page = PageRequest.of(page.getPageNumber(), page.getPageSize(), + Sort.by(Sort.Direction.DESC, "createTime")); + } + Page 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; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/utils/GzipUtil.java b/src/main/java/com/yunhai/bschclassmanage/utils/GzipUtil.java new file mode 100644 index 0000000..d12c319 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/utils/GzipUtil.java @@ -0,0 +1,40 @@ +package com.yunhai.bschclassmanage.utils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * GZIP 压缩/解压,用于审计日志响应体(MEDIUMBLOB)存储。 + */ +public class GzipUtil { + private static final int BUFFER_SIZE = 4096; + + public static byte[] compress(String data) { + if (data == null) return null; + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(data.getBytes(StandardCharsets.UTF_8)); + gzip.finish(); + return out.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("GZIP 压缩失败", e); + } + } + + public static String decompress(byte[] data) { + if (data == null || data.length == 0) return null; + try (ByteArrayInputStream in = new ByteArrayInputStream(data); + GZIPInputStream gzip = new GZIPInputStream(in); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[BUFFER_SIZE]; + int n; + while ((n = gzip.read(buf)) > 0) out.write(buf, 0, n); + return out.toString(StandardCharsets.UTF_8.name()); + } catch (Exception e) { + throw new RuntimeException("GZIP 解压失败", e); + } + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/utils/OperationLogResolver.java b/src/main/java/com/yunhai/bschclassmanage/utils/OperationLogResolver.java new file mode 100644 index 0000000..a9cdec3 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/utils/OperationLogResolver.java @@ -0,0 +1,38 @@ +package com.yunhai.bschclassmanage.utils; + +import com.yunhai.bschclassmanage.enums.LogLevel; +import org.apache.commons.lang3.StringUtils; + +/** + * 审计日志级别/类型解析:根据 HTTP 方法 + URI + 方法名推断。 + * 对齐本项目命名(save/import/upload 视为新增,delete/del/remove 视为删除)。 + */ +public class OperationLogResolver { + private static final String[] DELETE_KEYWORDS = {"delete", "del", "remove"}; + private static final String[] CREATE_KEYWORDS = {"add", "create", "insert", "save", "import", "upload"}; + + public static LogLevel resolveLevel(String httpMethod, String uri, String methodName) { + if (StringUtils.isBlank(httpMethod)) return LogLevel.SECONDARY; + String m = httpMethod.toUpperCase(); + if ("GET".equals(m)) return LogLevel.GENERAL; + if ("DELETE".equals(m)) return LogLevel.IMPORTANT; + if (containsAny(uri, methodName, DELETE_KEYWORDS)) return LogLevel.IMPORTANT; + return LogLevel.SECONDARY; + } + + public static String resolveOperationType(String httpMethod, String uri, String methodName) { + if (StringUtils.isBlank(httpMethod)) return "UPDATE"; + String m = httpMethod.toUpperCase(); + if ("GET".equals(m)) return "QUERY"; + if ("DELETE".equals(m)) return "DELETE"; + if (containsAny(uri, methodName, DELETE_KEYWORDS)) return "DELETE"; + if (containsAny(uri, methodName, CREATE_KEYWORDS)) return "CREATE"; + return "UPDATE"; + } + + private static boolean containsAny(String uri, String methodName, String[] keywords) { + String text = StringUtils.lowerCase(uri) + " " + StringUtils.lowerCase(methodName); + for (String kw : keywords) if (StringUtils.contains(text, kw)) return true; + return false; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/utils/SensitiveDataUtil.java b/src/main/java/com/yunhai/bschclassmanage/utils/SensitiveDataUtil.java new file mode 100644 index 0000000..15b8686 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/utils/SensitiveDataUtil.java @@ -0,0 +1,47 @@ +package com.yunhai.bschclassmanage.utils; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 日志脱敏:对 request_params / response_data 中的敏感字段值掩码为 *** + * 默认敏感字段:password,pwd,secret,token,appSecret,accessKey,idCard,phone,mobile,credential + * 脱敏失败返回原文,绝不阻断主流程。 + */ +public class SensitiveDataUtil { + + private static volatile Set 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; + } + } +} diff --git a/操作日志落地方案.md b/操作日志落地方案.md new file mode 100644 index 0000000..8aee73a --- /dev/null +++ b/操作日志落地方案.md @@ -0,0 +1,878 @@ +# 操作日志方案 · 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 过滤 | 物理隔离,彻底互不污染 | + +**双轨优势**:业务日志轨零风险(不动任何现有代码),审计轨独立演进(可单独调优、单独清理、单独扩容),两轨职责清晰——业务日志看"做了什么",审计日志看"调了什么"。