运维页面

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 com.yunhai.bschclassmanage.entity.SyncLog;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@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.config.Config;
import com.yunhai.bschclassmanage.controller.BaseController; import com.yunhai.bschclassmanage.controller.BaseController;
import com.yunhai.bschclassmanage.entity.SysOperationLog; import com.yunhai.bschclassmanage.entity.SysOperationLog;
import com.yunhai.bschclassmanage.enums.UserType;
import com.yunhai.bschclassmanage.model.vo.PageVO; import com.yunhai.bschclassmanage.model.vo.PageVO;
import com.yunhai.bschclassmanage.service.ISysOperationLogService; import com.yunhai.bschclassmanage.service.ISysOperationLogService;
import com.yunhai.bschclassmanage.utils.UserContext; import com.yunhai.bschclassmanage.utils.UserContext;
import com.yunhai.platform.utils.exception.DException;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -16,6 +18,8 @@ import org.springframework.web.bind.annotation.*;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
@RestController @RestController
@RequestMapping(Config.API_PATH + "/syslog/") @RequestMapping(Config.API_PATH + "/syslog/")
@@ -25,22 +29,35 @@ public class SysOperationLogController extends BaseController {
@Autowired @Autowired
private ISysOperationLogService sysOperationLogService; private ISysOperationLogService sysOperationLogService;
/** 审计日志分页查询(默认按当前用户 schId 隔离) */ /**
* 超管校验:非超级管理员直接抛异常阻断(运维工作台仅超级管理员可访问)
*/
private void denyIfNotSuperAdmin() {
boolean isSuperAdmin = UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType());
if (!isSuperAdmin) {
throw new DException("无权限:仅超级管理员可访问接口日志");
}
}
/** 审计日志分页查询;schId 为"所选学校"筛选条件(非登录用户隔离),不传则查全部学校 */
@GetMapping("list") @GetMapping("list")
public PageVO<SysOperationLog> list( public PageVO<SysOperationLog> list(
@RequestParam(required = false) Integer schId,
@RequestParam(required = false) String uri, @RequestParam(required = false) String uri,
@RequestParam(required = false) String method, @RequestParam(required = false) String method,
@RequestParam(required = false) Integer statusCode, @RequestParam(required = false) Integer statusCode,
@RequestParam(required = false) Integer logLevel, @RequestParam(required = false) Integer logLevel,
@RequestParam(required = false) Integer userId, @RequestParam(required = false) String userName,
Pageable pageable) { Pageable pageable) {
return sysOperationLogService.pageQuery(UserContext.getSchId(), uri, method, denyIfNotSuperAdmin();
statusCode, logLevel, userId, pageable); return sysOperationLogService.pageQuery(schId, uri, method,
statusCode, logLevel, userName, pageable);
} }
/** 下载某条响应原文(GZIP 解压) */ /** 下载某条响应原文(GZIP 解压) */
@GetMapping("download/{id}") @GetMapping("download/{id}")
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) { public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
denyIfNotSuperAdmin();
String data = sysOperationLogService.getResponseData(id); String data = sysOperationLogService.getResponseData(id);
if (data == null) data = "该条审计日志未记录响应数据"; if (data == null) data = "该条审计日志未记录响应数据";
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
@@ -55,4 +72,16 @@ public class SysOperationLogController extends BaseController {
.contentType(MediaType.parseMediaType("application/octet-stream")) .contentType(MediaType.parseMediaType("application/octet-stream"))
.body(new InputStreamResource(bis)); .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; package com.yunhai.bschclassmanage.service;
import com.yunhai.bschclassmanage.entity.SyncLog; import com.yunhai.bschclassmanage.entity.SyncLog;
import com.yunhai.bschclassmanage.model.vo.PageVO;
import org.springframework.data.domain.Pageable;
public interface ISyncLogService { public interface ISyncLogService {
/** /**
@@ -8,4 +10,18 @@ public interface ISyncLogService {
* 由 SyncLogAspect 在 finally 中调用,失败仅记日志、不抛异常、不阻塞主流程。 * 由 SyncLogAspect 在 finally 中调用,失败仅记日志、不抛异常、不阻塞主流程。
*/ */
void saveAsync(SyncLog entity); 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 投影;实体直查,初版简单先行) */ /** 分页查询(不含 response_data 投影;实体直查,初版简单先行) */
PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method, PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method,
Integer statusCode, Integer logLevel, Integer statusCode, Integer logLevel,
Integer userId, Pageable pageable); String userName, Pageable pageable);
/** 取单条(含 response_data)用于下载 */ /** 取单条(含 response_data)用于下载 */
SysOperationLog getById(Long id); SysOperationLog getById(Long id);
@@ -19,6 +19,9 @@ public interface ISysOperationLogService {
/** 解压某条响应原文 */ /** 解压某条响应原文 */
String getResponseData(Long id); String getResponseData(Long id);
/** 重试:按记录的 classMethod + requestParams 反射重放原始操作(仅失败记录可重试) */
Object retry(Long id);
/** 定时清理,返回删除条数 */ /** 定时清理,返回删除条数 */
long cleanupExpired(); long cleanupExpired();
} }
@@ -1,13 +1,33 @@
package com.yunhai.bschclassmanage.service.impl; package com.yunhai.bschclassmanage.service.impl;
import com.alibaba.fastjson.JSON;
import com.yunhai.bschclassmanage.Repository.SyncLogRepository; import com.yunhai.bschclassmanage.Repository.SyncLogRepository;
import com.yunhai.bschclassmanage.entity.SyncLog; import com.yunhai.bschclassmanage.entity.SyncLog;
import com.yunhai.bschclassmanage.model.vo.PageVO;
import com.yunhai.bschclassmanage.service.ISyncLogService; 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 lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; 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.scheduling.annotation.Async;
import org.springframework.stereotype.Service; 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 @Slf4j
@Service @Service
public class SyncLogServiceImpl implements ISyncLogService { public class SyncLogServiceImpl implements ISyncLogService {
@@ -15,6 +35,9 @@ public class SyncLogServiceImpl implements ISyncLogService {
@Autowired @Autowired
private SyncLogRepository repository; private SyncLogRepository repository;
@Autowired
private ApplicationContext applicationContext;
@Async("logExecutor") @Async("logExecutor")
@Override @Override
public void saveAsync(SyncLog entity) { public void saveAsync(SyncLog entity) {
@@ -24,4 +47,97 @@ public class SyncLogServiceImpl implements ISyncLogService {
log.error("同步日志异步落库失败, classMethod={}", entity.getClassMethod(), e); 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; package com.yunhai.bschclassmanage.service.impl;
import com.alibaba.fastjson.JSONArray;
import com.yunhai.bschclassmanage.Repository.SysOperationLogRepository; import com.yunhai.bschclassmanage.Repository.SysOperationLogRepository;
import com.yunhai.bschclassmanage.entity.SysOperationLog; import com.yunhai.bschclassmanage.entity.SysOperationLog;
import com.yunhai.bschclassmanage.model.vo.PageVO; import com.yunhai.bschclassmanage.model.vo.PageVO;
import com.yunhai.bschclassmanage.service.ISysOperationLogService; import com.yunhai.bschclassmanage.service.ISysOperationLogService;
import com.yunhai.bschclassmanage.utils.GzipUtil; 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 lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -19,6 +24,9 @@ import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import javax.persistence.criteria.Predicate; 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.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -33,6 +41,9 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
@Autowired @Autowired
private SysOperationLogRepository repository; private SysOperationLogRepository repository;
@Autowired
private ApplicationContext applicationContext;
@Async("auditLogExecutor") @Async("auditLogExecutor")
@Override @Override
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -48,7 +59,7 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
@Override @Override
public PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method, public PageVO<SysOperationLog> pageQuery(Integer schId, String uri, String method,
Integer statusCode, Integer logLevel, Integer statusCode, Integer logLevel,
Integer userId, Pageable pageable) { String userName, Pageable pageable) {
Specification<SysOperationLog> spec = (root, query, cb) -> { Specification<SysOperationLog> spec = (root, query, cb) -> {
List<Predicate> ps = new ArrayList<>(); List<Predicate> ps = new ArrayList<>();
if (schId != null) ps.add(cb.equal(root.get("schId").as(Integer.class), schId)); 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 (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 (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 (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])); return cb.and(ps.toArray(new Predicate[0]));
}; };
Pageable page = pageable; Pageable page = pageable;
@@ -93,4 +105,77 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
deleted, generalDeadline, importantDeadline); deleted, generalDeadline, importantDeadline);
return deleted; 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);
}
}
} }
-505
View File
@@ -1,505 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Redis 缓存查看(超管)</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a212b;
--panel-2: #222c38;
--border: #2c3845;
--text: #d7e0ea;
--muted: #8b9aab;
--accent: #4c9aff;
--accent-2: #2f6fd1;
--green: #36c275;
--red: #ff6b6b;
--yellow: #f7c948;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #16202c, #0f1419);
}
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
header .sub { margin-top: 4px; color: var(--muted); font-size: 12px; }
.wrap { display: flex; gap: 16px; padding: 16px 24px; align-items: flex-start; }
.left { width: 320px; flex: 0 0 320px; }
.right { flex: 1 1 auto; min-width: 0; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.card + .card { margin-top: 16px; }
.field { margin-bottom: 12px; }
.field:last-child { margin-bottom: 0; }
label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
input[type=text] {
width: 100%;
padding: 9px 11px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: var(--mono);
outline: none;
}
input[type=text]:focus { border-color: var(--accent); }
.btn {
padding: 9px 14px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent-2); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: var(--accent); }
.btn.danger { background: transparent; border-color: var(--red); color: var(--red); }
.btn.danger:hover { background: rgba(255,107,107,.12); }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.keys-list { max-height: 60vh; overflow: auto; margin-top: 10px; border-top: 1px solid var(--border); }
.keys-list .k {
padding: 8px 10px;
font-family: var(--mono);
font-size: 12px;
border-bottom: 1px solid var(--border);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.keys-list .k:hover { background: var(--panel-2); }
.keys-list .k.active { background: rgba(76,154,255,.15); border-left: 3px solid var(--accent); padding-left: 7px; }
.keys-list .k .kname {
display: block; color: var(--accent); font-size: 11px; margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.keys-list .k .kkey {
display: block; font-family: var(--mono); font-size: 12px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.meta { display: grid; grid-template-columns: 90px 1fr; gap: 10px 14px; margin-bottom: 14px; }
.meta .mk { color: var(--muted); font-size: 12px; }
.meta .mv { font-family: var(--mono); word-break: break-all; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; border: 1px solid var(--border); color: var(--muted);
}
.badge.string { color: var(--green); border-color: rgba(54,194,117,.4); }
.badge.hash { color: var(--accent); border-color: rgba(76,154,255,.4); }
.badge.list, .badge.set, .badge.zset { color: var(--yellow); border-color: rgba(247,201,72,.4); }
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.count { color: var(--muted); font-size: 12px; }
select {
padding: 7px 10px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
outline: none;
cursor: pointer;
}
select:focus { border-color: var(--accent); }
pre.json {
margin: 0; padding: 12px; background: #0b0f14; border: 1px solid var(--border);
border-radius: 8px; overflow: auto; max-height: 60vh; font-family: var(--mono);
font-size: 12px; line-height: 1.5; color: #cfe3ff; white-space: pre;
}
pre.json .jk { color: #e06c75; } /* key */
pre.json .js { color: #98c379; } /* string value */
pre.json .jn { color: #d19a66; } /* number */
pre.json .jb { color: #c678dd; } /* boolean / null */
.empty { color: var(--muted); padding: 24px; text-align: center; }
.toast {
position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%);
background: #2a1d1d; border: 1px solid var(--red); color: #ffd5d5;
padding: 10px 16px; border-radius: 8px; font-size: 13px; max-width: 80vw;
display: none; z-index: 50;
}
.toast.ok { background: #1d2a20; border-color: var(--green); color: #c9f4d8; }
.toast.show { display: block; }
.hint { color: var(--muted); font-size: 11px; margin-top: 6px; }
.spinner {
display: inline-block; width: 14px; height: 14px; border: 2px solid var(--muted);
border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite;
vertical-align: -2px; margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
code { background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-family: var(--mono); }
</style>
</head>
<body>
<header>
<h1>Redis 缓存查看 <span style="color:var(--accent)">(超管)</span></h1>
<div class="sub">仅超级管理员可访问。部署后同源访问自动带登录态。</div>
</header>
<div class="wrap">
<div class="left">
<div class="card">
<div class="field">
<label>按 Key 精确查询</label>
<input type="text" id="keyInput" placeholder="例如 bsch.classmanage.sch_class_1" autocomplete="off"/>
</div>
<div class="btn-row">
<button class="btn primary" id="btnQuery">查询</button>
<button class="btn" id="btnClear">清空</button>
</div>
<div class="field" style="margin-top:14px">
<label>按模式列出 Keys(支持 * ? )</label>
<input type="text" id="patternInput" placeholder="例如 bsch.classmanage.*" autocomplete="off"/>
</div>
<div class="btn-row">
<button class="btn" id="btnList">列出 Keys</button>
</div>
<div class="hint">生产大库下 KEYS 会阻塞,请尽量指定明确前缀,勿用单独的 <code>*</code>。</div>
<div class="keys-list" id="keysList"></div>
</div>
</div>
<div class="right">
<div class="card" id="detailCard">
<div class="empty" id="detailEmpty">输入 Key 或列出 Keys 后点击查询,结果将显示在此处。</div>
<div id="detailBody" style="display:none">
<div class="meta">
<div class="mk">Key</div><div class="mv" id="mKey"></div>
<div class="mk">缓存名称</div><div class="mv" id="mCacheName"></div>
<div class="mk">类型</div><div class="mv" id="mType"></div>
<div class="mk">过期时间</div><div class="mv" id="mExpire"></div>
<div class="mk">大小</div><div class="mv" id="mSize"></div>
</div>
<div class="toolbar">
<select id="formatSelect" title="格式">
<option value="json" selected>JSON</option>
<option value="text">Text</option>
</select>
<button class="btn" id="btnCopy" title="复制当前内容">复制</button>
<button class="btn" id="btnRefresh" title="刷新当前 Key">刷新</button>
<span class="count" id="rowCount"></span>
<span style="flex:1"></span>
<button class="btn danger" id="btnDelete">删除此 Key</button>
</div>
<pre class="json" id="valueJson"></pre>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// 同源时从页面自身路径推导 API 基址(兼容 context-path = 根 / 或 /bschclass)
function resolveApi() {
const base = location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1);
return base + 'api/cache';
}
const API = resolveApi();
let currentKey = null;
let currentData = null;
let currentValue = null;
let currentFormat = 'json';
const $ = (id) => document.getElementById(id);
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 缓存名称映射:prefix 与 Key 精确相等或以 prefix_ 开头即命中。
// 注意:数组须按前缀长度降序排列,避免 sch_class 抢走 sch_class_teach / sch_class_student。
const CACHE_NAMES = [
{ prefix: 'bsch.classmanage.sch_class_student', name: '班级学生缓存' },
{ prefix: 'bsch.classmanage.sch_class_teach', name: '班级任教缓存' },
{ prefix: 'bsch.classmanage.sch_group_teacher', name: '教师组缓存' },
{ prefix: 'bsch.classmanage.sch_class', name: '班级缓存' },
{ prefix: 'bsch.classmanage.external_plan_data',name: '执行任务数据缓存' },
{ prefix: 'bsch.classmanage.external_progress', name: '执行进度缓存' },
{ prefix: 'bsch.classmanage.system_group_tree', name: '全区树缓存' },
{ prefix: 'bsch.classmanage.system_schsystem', name: '系统学制缓存' },
{ prefix: 'bsch.classmanage.system_yearterm', name: '系统学年学期缓存' },
{ prefix: 'bsch.classmanage.system_subject', name: '系统科目缓存' },
{ prefix: 'bsch.classmanage.system_grdae', name: '系统年级缓存' },
{ prefix: 'bsch.classmanage.sch_tree', name: '学校树缓存' },
{ prefix: 'bsch.classmanage.sch_list', name: '学校列表缓存' },
{ prefix: 'bsch.classmanage.sch_subject', name: '学校科目缓存' },
{ prefix: 'bsch.classmanage.sch_campus', name: '学校院校缓存' },
{ prefix: 'bsch.classmanage.sch_division', name: '学部缓存' },
{ prefix: 'bsch.classmanage.sch_majors', name: '专业缓存' },
{ prefix: 'bsch.classmanage.sch_course', name: '课程缓存' },
{ prefix: 'bsch.classmanage.sch_grdae', name: '学校年级缓存' },
{ prefix: 'bsch.classmanage.sch_teacher', name: '教师缓存' }
];
function cacheName(key) {
if (!key) return '';
for (const c of CACHE_NAMES) {
if (key === c.prefix || key.startsWith(c.prefix + '_')) return c.name;
}
return '';
}
function authHeaders() {
return { 'Accept': 'application/json' };
}
function toast(msg, ok) {
const t = $('toast');
t.textContent = msg;
t.className = 'toast show' + (ok ? ' ok' : '');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), 3500);
}
function showLoading(btn, text) {
btn.dataset.old = btn.textContent;
btn.innerHTML = '<span class="spinner"></span>' + text;
btn.disabled = true;
}
function hideLoading(btn) {
btn.textContent = btn.dataset.old || btn.textContent;
btn.disabled = false;
}
function formatSize(bytes) {
if (bytes == null || bytes < 0) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}
// 检测被 CAS 登录拦截(302 重定向到登录页后再被 fetch 跟随后返回 HTML)
function checkLoginRedirect(res) {
const ct = res.headers.get('content-type') || '';
if (res.redirected || !ct.includes('json')) {
// 页面地址动态推导:开发 http://192.168.10.166:8086/,生产 http://base.yunhaixingtong.com/bschclass/
const pageUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/') + 1) + 'cache-view.html';
return '未登录或登录态无效(请求被重定向到 CAS 登录页)。' +
'请在「同一个浏览器、已 CAS 登录且为超级管理员」的会话下访问:' + pageUrl;
}
return null;
}
async function apiGet(path, params) {
const url = API + path + '?' + new URLSearchParams(params).toString();
const res = await fetch(url, { credentials: 'include', headers: authHeaders() });
const loginErr = checkLoginRedirect(res);
let data = null;
try { data = await res.json(); } catch (e) { /* ignore */ }
if (loginErr) throw new Error(loginErr);
if (!res.ok) {
const msg = (data && (data.msg || data.message)) || ('请求失败 HTTP ' + res.status);
throw new Error(msg);
}
// 统一解包:后端统一返回 { code, msg, data } 结构,成功(code===0)时取内层 data
if (data && typeof data === 'object' && 'code' in data && 'data' in data) {
if (data.code !== 0) {
throw new Error(data.msg || data.message || ('业务错误 code=' + data.code));
}
return data.data;
}
return data;
}
async function queryByKey(key) {
if (!key) { toast('请输入 Key'); return; }
const btn = $('btnQuery');
showLoading(btn, '查询中');
try {
const data = await apiGet('/get', { key });
currentKey = key;
currentData = data;
renderDetail(data);
markActiveKey(key);
} catch (e) {
toast('查询失败:' + e.message);
} finally {
hideLoading(btn);
}
}
async function listKeys(pattern) {
pattern = pattern || '*';
const btn = $('btnList');
showLoading(btn, '扫描中');
try {
const keys = await apiGet('/keys', { pattern });
renderKeys(Array.from(keys || []).sort());
} catch (e) {
toast('列出失败:' + e.message);
} finally {
hideLoading(btn);
}
}
function renderKeys(keys) {
const box = $('keysList');
box.innerHTML = '';
if (!keys.length) {
box.innerHTML = '<div class="k" style="cursor:default;color:var(--muted)">未匹配到 Key</div>';
return;
}
keys.forEach(k => {
const div = document.createElement('div');
div.className = 'k';
const name = cacheName(k);
div.innerHTML = (name ? '<span class="kname">' + escHtml(name) + '</span>' : '') +
'<span class="kkey">' + escHtml(k) + '</span>';
div.title = k;
div.onclick = () => { $('keyInput').value = k; queryByKey(k); };
box.appendChild(div);
});
}
function markActiveKey(key) {
document.querySelectorAll('#keysList .k').forEach(el => {
el.classList.toggle('active', el.textContent === key);
});
}
function renderDetail(data) {
$('detailEmpty').style.display = 'none';
$('detailBody').style.display = 'block';
$('mKey').textContent = data.key || '';
$('mCacheName').textContent = cacheName(data.key) || '-';
const type = data.type || '-';
$('mType').innerHTML = '<span class="badge ' + type + '">' + type + '</span>';
const expireSec = data.expire;
let exp = data.expireText || '';
if (expireSec !== undefined && expireSec !== null && expireSec >= 0) {
exp += '(' + expireSec + ' 秒)';
}
$('mExpire').textContent = exp || '-';
$('mSize').textContent = (data.size !== undefined ? formatSize(data.size) : '-') +
(data.size !== undefined ? '(' + data.size + ' 字节)' : '');
renderValue(data.value);
}
function renderValue(value) {
currentValue = value;
currentData = Object.assign({}, currentData, { value });
const valType = Array.isArray(value) ? 'array' : (value && typeof value === 'object' ? 'object' : 'scalar');
const count = Array.isArray(value) ? value.length : (value && typeof value === 'object' ? Object.keys(value).length : 1);
renderFormatted();
$('rowCount').textContent = '类型=' + valType + ' · 数量=' + count;
}
function renderFormatted() {
if (currentValue === undefined || currentValue === null) return;
const value = currentValue;
if (currentFormat === 'text') {
const text = JSON.stringify(value);
$('valueJson').textContent = text;
} else {
const json = JSON.stringify(value, null, 2);
$('valueJson').innerHTML = highlightJson(json);
}
}
function highlightJson(json) {
let html = escHtml(json);
// 高亮 key(引号后跟冒号)
html = html.replace(/("(?:\\.|[^"\\])*")(\s*:)/g, '<span class="jk">$1</span>$2');
// 高亮 string value(冒号+空白后跟引号串)
html = html.replace(/(:\s*)("(?:\\.|[^"\\])*")/g, '$1<span class="js">$2</span>');
// 高亮 number
html = html.replace(/(:\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g, '$1<span class="jn">$2</span>');
// 高亮 boolean / null
html = html.replace(/(:\s*)(true|false|null)\b/g, '$1<span class="jb">$2</span>');
return html;
}
async function deleteKey() {
if (!currentKey) return;
if (!confirm('确认删除 Key:\n' + currentKey + '\n此操作不可恢复!')) return;
const btn = $('btnDelete');
showLoading(btn, '删除中');
try {
const res = await fetch(API + '/delete?key=' + encodeURIComponent(currentKey), {
method: 'POST', credentials: 'include', headers: authHeaders()
});
const loginErr = checkLoginRedirect(res);
let data = null; try { data = await res.json(); } catch (e) {}
if (loginErr) throw new Error(loginErr);
if (!res.ok) throw new Error((data && (data.msg || data.message)) || ('HTTP ' + res.status));
toast('已删除:' + (data.msg || ''), true);
$('detailBody').style.display = 'none';
$('detailEmpty').style.display = 'block';
currentKey = null; currentData = null;
} catch (e) {
toast('删除失败:' + e.message);
} finally {
hideLoading(btn);
}
}
async function copyValue() {
if (currentValue === null || currentValue === undefined) { toast('当前没有可复制的内容'); return; }
const text = currentFormat === 'text' ? JSON.stringify(currentValue) : JSON.stringify(currentValue, null, 2);
try {
await navigator.clipboard.writeText(text);
toast('已复制到剪贴板', true);
} catch (e) {
// 降级方案
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
toast('已复制到剪贴板', true);
} catch (err) {
toast('复制失败:' + err.message);
}
document.body.removeChild(ta);
}
}
function refreshValue() {
if (!currentKey) { toast('当前没有 Key 可刷新'); return; }
queryByKey(currentKey);
}
function changeFormat(fmt) {
currentFormat = fmt || $('formatSelect').value;
$('formatSelect').value = currentFormat;
renderFormatted();
}
// 事件绑定
$('btnQuery').onclick = () => queryByKey($('keyInput').value.trim());
$('btnList').onclick = () => listKeys($('patternInput').value.trim() || '*');
$('btnClear').onclick = () => { $('keyInput').value = ''; $('patternInput').value = ''; $('keysList').innerHTML = ''; };
$('btnDelete').onclick = deleteKey;
$('btnCopy').onclick = copyValue;
$('btnRefresh').onclick = refreshValue;
$('formatSelect').onchange = () => changeFormat();
$('keyInput').addEventListener('keydown', e => { if (e.key === 'Enter') queryByKey(e.target.value.trim()); });
$('patternInput').addEventListener('keydown', e => { if (e.key === 'Enter') listKeys(e.target.value.trim() || '*'); });
</script>
</body>
</html>