diff --git a/src/main/java/com/yunhai/bschclassmanage/Repository/SyncLogRepository.java b/src/main/java/com/yunhai/bschclassmanage/Repository/SyncLogRepository.java index 1773698..2d4b63d 100644 --- a/src/main/java/com/yunhai/bschclassmanage/Repository/SyncLogRepository.java +++ b/src/main/java/com/yunhai/bschclassmanage/Repository/SyncLogRepository.java @@ -2,8 +2,10 @@ package com.yunhai.bschclassmanage.Repository; import com.yunhai.bschclassmanage.entity.SyncLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @Repository -public interface SyncLogRepository extends JpaRepository { +public interface SyncLogRepository + extends JpaRepository, JpaSpecificationExecutor { } diff --git a/src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java b/src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java index 6e00a41..41d0805 100644 --- a/src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java +++ b/src/main/java/com/yunhai/bschclassmanage/controller/api/SysOperationLogController.java @@ -3,9 +3,11 @@ 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.enums.UserType; import com.yunhai.bschclassmanage.model.vo.PageVO; import com.yunhai.bschclassmanage.service.ISysOperationLogService; import com.yunhai.bschclassmanage.utils.UserContext; +import com.yunhai.platform.utils.exception.DException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Pageable; @@ -16,6 +18,8 @@ import org.springframework.web.bind.annotation.*; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; @RestController @RequestMapping(Config.API_PATH + "/syslog/") @@ -25,22 +29,35 @@ public class SysOperationLogController extends BaseController { @Autowired private ISysOperationLogService sysOperationLogService; - /** 审计日志分页查询(默认按当前用户 schId 隔离) */ + /** + * 超管校验:非超级管理员直接抛异常阻断(运维工作台仅超级管理员可访问) + */ + private void denyIfNotSuperAdmin() { + boolean isSuperAdmin = UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType()); + if (!isSuperAdmin) { + throw new DException("无权限:仅超级管理员可访问接口日志"); + } + } + + /** 审计日志分页查询;schId 为"所选学校"筛选条件(非登录用户隔离),不传则查全部学校 */ @GetMapping("list") public PageVO list( + @RequestParam(required = false) Integer schId, @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, + @RequestParam(required = false) String userName, Pageable pageable) { - return sysOperationLogService.pageQuery(UserContext.getSchId(), uri, method, - statusCode, logLevel, userId, pageable); + denyIfNotSuperAdmin(); + return sysOperationLogService.pageQuery(schId, uri, method, + statusCode, logLevel, userName, pageable); } /** 下载某条响应原文(GZIP 解压) */ @GetMapping("download/{id}") public ResponseEntity download(@PathVariable Long id) { + denyIfNotSuperAdmin(); String data = sysOperationLogService.getResponseData(id); if (data == null) data = "该条审计日志未记录响应数据"; ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); @@ -55,4 +72,16 @@ public class SysOperationLogController extends BaseController { .contentType(MediaType.parseMediaType("application/octet-stream")) .body(new InputStreamResource(bis)); } + + /** 重试某条失败的操作:按记录中的 classMethod + requestParams 反射重放原始操作 */ + @PostMapping("retry/{id}") + public Map retry(@PathVariable Long id) { + denyIfNotSuperAdmin(); + Object result = sysOperationLogService.retry(id); + Map m = new LinkedHashMap<>(); + m.put("success", true); + m.put("message", "重试已提交,执行结果见新增的审计日志"); + if (result != null) m.put("data", result); + return m; + } } diff --git a/src/main/java/com/yunhai/bschclassmanage/service/ISyncLogService.java b/src/main/java/com/yunhai/bschclassmanage/service/ISyncLogService.java index ba4b0c0..ed065b5 100644 --- a/src/main/java/com/yunhai/bschclassmanage/service/ISyncLogService.java +++ b/src/main/java/com/yunhai/bschclassmanage/service/ISyncLogService.java @@ -1,6 +1,8 @@ package com.yunhai.bschclassmanage.service; import com.yunhai.bschclassmanage.entity.SyncLog; +import com.yunhai.bschclassmanage.model.vo.PageVO; +import org.springframework.data.domain.Pageable; public interface ISyncLogService { /** @@ -8,4 +10,18 @@ public interface ISyncLogService { * 由 SyncLogAspect 在 finally 中调用,失败仅记日志、不抛异常、不阻塞主流程。 */ void saveAsync(SyncLog entity); + + /** 分页查询(默认按当前用户 schId 隔离) */ + PageVO pageQuery(Integer schId, String syncType, String syncTypeName, + String classMethod, Integer status, String userName, + Pageable pageable); + + /** 取单条(含 jsonData / errorMsg) */ + SyncLog getById(Long id); + + /** 取下载原文:优先 jsonData(请求入参),回退 errorMsg(异常信息) */ + String getDownloadData(Long id); + + /** 重试某条同步/推送调用:按 classMethod + jsonData 反射重放原始操作 */ + Object retry(Long id); } diff --git a/src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java b/src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java index 1eb3671..e8a2f8e 100644 --- a/src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java +++ b/src/main/java/com/yunhai/bschclassmanage/service/ISysOperationLogService.java @@ -11,7 +11,7 @@ public interface ISysOperationLogService { /** 分页查询(不含 response_data 投影;实体直查,初版简单先行) */ PageVO pageQuery(Integer schId, String uri, String method, Integer statusCode, Integer logLevel, - Integer userId, Pageable pageable); + String userName, Pageable pageable); /** 取单条(含 response_data)用于下载 */ SysOperationLog getById(Long id); @@ -19,6 +19,9 @@ public interface ISysOperationLogService { /** 解压某条响应原文 */ String getResponseData(Long id); + /** 重试:按记录的 classMethod + requestParams 反射重放原始操作(仅失败记录可重试) */ + Object retry(Long id); + /** 定时清理,返回删除条数 */ long cleanupExpired(); } diff --git a/src/main/java/com/yunhai/bschclassmanage/service/impl/SyncLogServiceImpl.java b/src/main/java/com/yunhai/bschclassmanage/service/impl/SyncLogServiceImpl.java index e9d6d60..369501d 100644 --- a/src/main/java/com/yunhai/bschclassmanage/service/impl/SyncLogServiceImpl.java +++ b/src/main/java/com/yunhai/bschclassmanage/service/impl/SyncLogServiceImpl.java @@ -1,13 +1,33 @@ package com.yunhai.bschclassmanage.service.impl; +import com.alibaba.fastjson.JSON; import com.yunhai.bschclassmanage.Repository.SyncLogRepository; import com.yunhai.bschclassmanage.entity.SyncLog; +import com.yunhai.bschclassmanage.model.vo.PageVO; import com.yunhai.bschclassmanage.service.ISyncLogService; +import com.yunhai.bschclassmanage.utils.ReflectRetryUtil; +import com.yunhai.platform.utils.exception.DException; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +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 javax.persistence.criteria.Predicate; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @Slf4j @Service public class SyncLogServiceImpl implements ISyncLogService { @@ -15,6 +35,9 @@ public class SyncLogServiceImpl implements ISyncLogService { @Autowired private SyncLogRepository repository; + @Autowired + private ApplicationContext applicationContext; + @Async("logExecutor") @Override public void saveAsync(SyncLog entity) { @@ -24,4 +47,97 @@ public class SyncLogServiceImpl implements ISyncLogService { log.error("同步日志异步落库失败, classMethod={}", entity.getClassMethod(), e); } } + + @Override + public PageVO pageQuery(Integer schId, String syncType, String syncTypeName, + String classMethod, Integer status, String userName, + 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(syncType)) ps.add(cb.equal(root.get("syncType").as(String.class), syncType)); + if (StringUtils.isNotBlank(syncTypeName)) + ps.add(cb.like(root.get("syncTypeName").as(String.class), "%" + syncTypeName + "%")); + if (StringUtils.isNotBlank(classMethod)) + ps.add(cb.like(root.get("classMethod").as(String.class), "%" + classMethod + "%")); + if (status != null) ps.add(cb.equal(root.get("status").as(Integer.class), status)); + if (StringUtils.isNotBlank(userName)) + ps.add(cb.like(root.get("userName").as(String.class), "%" + userName.trim() + "%")); + 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); + return new PageVO<>(p.getNumber() + 1, p.getSize(), p.getTotalElements(), p.getContent()); + } + + @Override + public SyncLog getById(Long id) { + return repository.findById(id).orElse(null); + } + + @Override + public String getDownloadData(Long id) { + SyncLog e = getById(id); + if (e == null) return "未找到该同步日志(id=" + id + ")"; + if (StringUtils.isNotBlank(e.getJsonData())) return e.getJsonData(); + if (StringUtils.isNotBlank(e.getErrorMsg())) return e.getErrorMsg(); + return "该条同步日志未记录请求数据/异常信息"; + } + + /** + * 重试:按 classMethod + jsonData 反射重放原始同步/推送调用。 + * - 仅允许失败/未判定(status != 1)的记录重试; + * - 经 Spring 代理调用,会再次走 @SyncOpLog 切面、生成新的同步日志; + * - 入参按位置映射(切面用 LinkedHashMap 按入参顺序生成 jsonData),覆盖基本类型/枚举/List/POJO; + * - 代理安全:支持 CGLIB / JDK 动态代理(见 ReflectRetryUtil); + * - 注意:原日志已对 pwd/idCard/phone 等脱敏为 ***,用户类增改重试可能因此失败(best-effort)。 + */ + @Override + public Object retry(Long id) { + SyncLog e = getById(id); + if (e == null) throw new DException("同步日志不存在(id=" + id + ")"); + if (e.getStatus() != null && e.getStatus() == 1) throw new DException("该同步已成功,无需重试"); + if (StringUtils.isBlank(e.getClassMethod())) throw new DException("该日志缺少调用信息(classMethod),无法重试"); + if (StringUtils.isBlank(e.getJsonData())) throw new DException("该日志缺少入参(jsonData),无法重试"); + + try { + @SuppressWarnings("unchecked") + Map argMap = JSON.parseObject(e.getJsonData(), LinkedHashMap.class); + if (argMap == null) throw new DException("入参解析失败,无法重试"); + + // 定位目标 Bean + Method(代理安全:支持 CGLIB / JDK 代理兜底扫描) + ReflectRetryUtil.Resolved resolved = ReflectRetryUtil.resolveBeanAndMethod( + applicationContext, e.getClassMethod(), argMap.size()); + + Method target = resolved.method; + Parameter[] params = target.getParameters(); + Object[] args = new Object[params.length]; + if (params.length > 0 && params[0].isNamePresent()) { + // 编译期保留参数名:按参数名匹配 + for (int i = 0; i < params.length; i++) { + args[i] = ReflectRetryUtil.convert(argMap.get(params[i].getName()), params[i]); + } + } else { + // 按位置匹配(切面保证 jsonData 与入参同序) + List values = new ArrayList<>(argMap.values()); + if (values.size() != params.length) { + throw new DException("入参个数不匹配,无法重试(日志=" + values.size() + ", 方法=" + params.length + ")"); + } + for (int i = 0; i < params.length; i++) { + args[i] = ReflectRetryUtil.convert(values.get(i), params[i]); + } + } + + Method invokeMethod = ReflectRetryUtil.getInvokeMethod(resolved.bean, target); + return invokeMethod.invoke(resolved.bean, args); + } catch (DException de) { + throw de; + } catch (InvocationTargetException | IllegalAccessException ex) { + throw ReflectRetryUtil.rethrow(ex); + } + } } diff --git a/src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java b/src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java index f2b6421..22caefe 100644 --- a/src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java +++ b/src/main/java/com/yunhai/bschclassmanage/service/impl/SysOperationLogServiceImpl.java @@ -1,13 +1,18 @@ package com.yunhai.bschclassmanage.service.impl; +import com.alibaba.fastjson.JSONArray; 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 com.yunhai.bschclassmanage.utils.ReflectRetryUtil; +import com.yunhai.bschclassmanage.utils.UserContext; +import com.yunhai.platform.utils.exception.DException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -19,6 +24,9 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import javax.persistence.criteria.Predicate; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -33,6 +41,9 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService { @Autowired private SysOperationLogRepository repository; + @Autowired + private ApplicationContext applicationContext; + @Async("auditLogExecutor") @Override @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -48,7 +59,7 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService { @Override public PageVO pageQuery(Integer schId, String uri, String method, Integer statusCode, Integer logLevel, - Integer userId, Pageable pageable) { + String userName, 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)); @@ -56,7 +67,8 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService { 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)); + if (StringUtils.isNotBlank(userName)) + ps.add(cb.like(root.get("userName").as(String.class), "%" + userName.trim() + "%")); return cb.and(ps.toArray(new Predicate[0])); }; Pageable page = pageable; @@ -93,4 +105,77 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService { deleted, generalDeadline, importantDeadline); return deleted; } + + /** + * 重试:按记录的 classMethod + requestParams 反射重放原始 Controller 操作。 + * 说明: + * - 仅允许 statusCode != 200(失败)的记录重试; + * - 仅支持入参为 JSON 数组形式(POST body / @RequestBody)。GET query 串无法可靠重建,直接拒绝; + * - 经 Spring 代理调用,会再次走 SysOperationLogAspect 切面、生成一条新的审计日志; + * - 重放前还原原操作人上下文(UserContext),使新审计日志记录正确的操作人; + * - servlet / Spring MVC 相关参数(HttpServletRequest 等)原由容器注入、日志未记录,重放时置 null; + * - 多校隔离:仅允许重试当前用户 schId 下的记录。 + */ + @Override + public Object retry(Long id) { + SysOperationLog e = getById(id); + if (e == null) throw new DException("审计日志不存在(id=" + id + ")"); + if (e.getStatusCode() != null && e.getStatusCode() == 200) { + throw new DException("该操作已成功(statusCode=200),无需重试"); + } + // 多校隔离:不允许重试其他学校的操作日志 + if (e.getSchId() != null && UserContext.getSchId() != null + && !e.getSchId().equals(UserContext.getSchId())) { + throw new DException("无权限重试其他学校的操作日志"); + } + if (StringUtils.isBlank(e.getClassMethod())) { + throw new DException("该日志记录缺少调用信息(classMethod),无法重试"); + } + if (StringUtils.isBlank(e.getRequestParams())) { + throw new DException("该日志记录缺少入参(requestParams),无法重试"); + } + // 仅支持 JSON 数组入参;GET query 串无法可靠重放 + if (!e.getRequestParams().trim().startsWith("[")) { + throw new DException("非 JSON 数组入参(如 GET query)无法可靠重放,请手动重试"); + } + + JSONArray arr; + try { + arr = JSONArray.parseArray(e.getRequestParams()); + } catch (Exception ex) { + throw new DException("入参解析失败(requestParams),无法重试"); + } + if (arr == null) throw new DException("入参解析失败(requestParams),无法重试"); + + ReflectRetryUtil.Resolved resolved = ReflectRetryUtil.resolveBeanAndMethod( + applicationContext, e.getClassMethod(), arr.size()); + + Method target = resolved.method; + Parameter[] params = target.getParameters(); + Object[] args = new Object[params.length]; + int jsonIdx = 0; + for (int i = 0; i < params.length; i++) { + if (ReflectRetryUtil.isSkippable(params[i].getType())) { + args[i] = null; // 容器注入参数,无法重建 + } else { + Object raw = (jsonIdx < arr.size()) ? arr.get(jsonIdx++) : null; + args[i] = ReflectRetryUtil.convert(raw, params[i]); + } + } + + // 还原原操作人上下文,使重放触发的审计切面记录正确操作人;结束后恢复本次请求上下文 + UserContext.UserInfo snapshot = UserContext.getCurrentUser(); + try { + UserContext.setUserId(e.getUserId()); + UserContext.setUserName(e.getUserName()); + UserContext.setSchId(e.getSchId()); + UserContext.setIp(e.getRequestIp()); + Method invokeMethod = ReflectRetryUtil.getInvokeMethod(resolved.bean, target); + return invokeMethod.invoke(resolved.bean, args); + } catch (InvocationTargetException | IllegalAccessException ex) { + throw ReflectRetryUtil.rethrow(ex); + } finally { + UserContext.setUserInfo(snapshot); + } + } } diff --git a/src/main/resources/static/cache-view.html b/src/main/resources/static/cache-view.html deleted file mode 100644 index 59da431..0000000 --- a/src/main/resources/static/cache-view.html +++ /dev/null @@ -1,505 +0,0 @@ - - - - - - Redis 缓存查看(超管) - - - -
-

Redis 缓存查看 (超管)

-
仅超级管理员可访问。部署后同源访问自动带登录态。
-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- -
-
生产大库下 KEYS 会阻塞,请尽量指定明确前缀,勿用单独的 *。
-
-
-
- -
-
-
输入 Key 或列出 Keys 后点击查询,结果将显示在此处。
- -
-
-
- -
- - - -