运维页面

This commit is contained in:
2026-08-20 21:49:46 +08:00
parent 54317837a9
commit f39a42dcf7
7 changed files with 259 additions and 513 deletions
@@ -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<SyncLog, Long> {
public interface SyncLogRepository
extends JpaRepository<SyncLog, Long>, JpaSpecificationExecutor<SyncLog> {
}
@@ -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<SysOperationLog> 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<InputStreamResource> 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<String, Object> retry(@PathVariable Long id) {
denyIfNotSuperAdmin();
Object result = sysOperationLogService.retry(id);
Map<String, Object> m = new LinkedHashMap<>();
m.put("success", true);
m.put("message", "重试已提交,执行结果见新增的审计日志");
if (result != null) m.put("data", result);
return m;
}
}
@@ -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<SyncLog> 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);
}
@@ -11,7 +11,7 @@ public interface ISysOperationLogService {
/** 分页查询(不含 response_data 投影;实体直查,初版简单先行) */
PageVO<SysOperationLog> 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();
}
@@ -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<SyncLog> pageQuery(Integer schId, String syncType, String syncTypeName,
String classMethod, Integer status, String userName,
Pageable pageable) {
Specification<SyncLog> spec = (root, query, cb) -> {
List<Predicate> 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<SyncLog> 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<String, Object> 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<Object> 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);
}
}
}
@@ -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<SysOperationLog> pageQuery(Integer schId, String uri, String method,
Integer statusCode, Integer logLevel,
Integer userId, Pageable pageable) {
String userName, Pageable pageable) {
Specification<SysOperationLog> spec = (root, query, cb) -> {
List<Predicate> 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);
}
}
}