This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package com.yunhai.userbase.aspect;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import com.yunhai.userbase.common.annotation.OapiLog;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.entity.SysOapiLog;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import com.yunhai.userbase.service.ISysOapiLogService;
|
||||
import com.yunhai.userbase.utils.GzipUtil;
|
||||
import com.yunhai.userbase.utils.IpUtils;
|
||||
import com.yunhai.userbase.utils.OperationLogResolver;
|
||||
import com.yunhai.userbase.utils.SensitiveDataMasker;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
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.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
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.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 开放接口(/oapi/**)调用日志切面。<br>
|
||||
* 拦截 {@code controller.oapi} 包下所有方法,规则:<br>
|
||||
* - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;<br>
|
||||
* - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;<br>
|
||||
* - 排除 {@code SignKeyInternalController}(/oapi/internal,响应含 appSecret 明文,绝不落库);<br>
|
||||
* - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;<br>
|
||||
* - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class OapiLogAspect {
|
||||
|
||||
/** 内部密钥端点控制器,排除(响应含 appSecret 明文)。 */
|
||||
private static final String INTERNAL_CONTROLLER = "com.yunhai.userbase.controller.oapi.SignKeyInternalController";
|
||||
|
||||
/** GET 默认不记录,开启本配置后全量记录。 */
|
||||
@Value("${oapi.log-get:false}")
|
||||
private boolean logGet;
|
||||
|
||||
@Autowired
|
||||
private ISysOapiLogService sysOapiLogService;
|
||||
|
||||
@Autowired
|
||||
private ApplicationInfoRepository applicationInfoRepository;
|
||||
|
||||
@Around("execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
if (INTERNAL_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
|
||||
return point.proceed();
|
||||
}
|
||||
|
||||
HttpServletRequest request = getRequest();
|
||||
String httpMethod = request != null ? request.getMethod() : "";
|
||||
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
|
||||
|
||||
OapiLog oapiLog = findAnnotation(point);
|
||||
// 决策:POST 全记;GET 仅当注解强制或配置开启才记
|
||||
if (isGet && oapiLog == null && !logGet) {
|
||||
return point.proceed();
|
||||
}
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
SysOapiLog entity = buildEntity(point, request, httpMethod, oapiLog);
|
||||
|
||||
boolean saveResponse = oapiLog != null ? oapiLog.saveResponse() : true;
|
||||
int maxResponseSize = oapiLog != null ? oapiLog.maxResponseSize() : 10240;
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = point.proceed();
|
||||
entity.setStatusCode(200);
|
||||
fillResponse(entity, result, saveResponse, maxResponseSize);
|
||||
} catch (Throwable e) {
|
||||
entity.setStatusCode(500);
|
||||
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||
throw e;
|
||||
} finally {
|
||||
entity.setCostTime(System.currentTimeMillis() - start);
|
||||
sysOapiLogService.saveAsync(entity);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private OapiLog findAnnotation(ProceedingJoinPoint point) {
|
||||
try {
|
||||
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
return method.getAnnotation(OapiLog.class);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SysOapiLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request,
|
||||
String httpMethod, OapiLog oapiLog) {
|
||||
SysOapiLog entity = new SysOapiLog();
|
||||
|
||||
// 调用方应用:验签 attribute → appKey → application_info 解析应用名(失败记 appKey 原值,不阻塞)
|
||||
resolveCallerApp(request, entity);
|
||||
|
||||
String requestUri = "";
|
||||
if (request != null) {
|
||||
entity.setRequestIp(IpUtils.getIpAddr(request));
|
||||
requestUri = request.getRequestURI();
|
||||
entity.setUserAgent(request.getHeader("User-Agent"));
|
||||
entity.setRequestParams(maskRequestParams(point, request));
|
||||
} else {
|
||||
entity.setRequestParams(maskRequestParams(point, null));
|
||||
}
|
||||
entity.setRequestUri(requestUri);
|
||||
entity.setRequestMethod(httpMethod);
|
||||
|
||||
String methodName = point.getSignature().getName();
|
||||
entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode());
|
||||
entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName));
|
||||
entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
|
||||
if (oapiLog != null && StringUtils.isNotBlank(oapiLog.value())) {
|
||||
entity.setOperationDesc(oapiLog.value());
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 request attribute 解析调用方 appKey,并映射应用名。<br>
|
||||
* 无 attribute / 解析失败:appKey 记 0、应用名记 unknown,仅 warn,不抛异常。
|
||||
*/
|
||||
private void resolveCallerApp(HttpServletRequest request, SysOapiLog entity) {
|
||||
entity.setCallerAppKey(0);
|
||||
entity.setCallerAppName("unknown");
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||
if (attr == null) {
|
||||
log.warn("[oapi-log] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
|
||||
request.getRequestURI());
|
||||
return;
|
||||
}
|
||||
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
|
||||
if (StringUtils.isBlank(appKeyStr)) {
|
||||
log.warn("[oapi-log] AppAuth attr is blank, uri={}", request.getRequestURI());
|
||||
return;
|
||||
}
|
||||
Integer appKey;
|
||||
try {
|
||||
appKey = Integer.parseInt(appKeyStr.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[oapi-log] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
|
||||
return;
|
||||
}
|
||||
entity.setCallerAppKey(appKey);
|
||||
ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey);
|
||||
entity.setCallerAppName(app != null && StringUtils.isNotBlank(app.getName())
|
||||
? app.getName() : String.valueOf(appKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建请求参数:GET 优先 queryString;其余序列化方法入参(过滤 Servlet 三大件),统一过脱敏。
|
||||
*/
|
||||
private String maskRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
|
||||
try {
|
||||
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
|
||||
String qs = request.getQueryString();
|
||||
if (StringUtils.isNotBlank(qs)) {
|
||||
return SensitiveDataMasker.maskJson(qs);
|
||||
}
|
||||
}
|
||||
Object[] args = point.getArgs();
|
||||
if (args == null || args.length == 0) {
|
||||
return "";
|
||||
}
|
||||
List<Object> filtered = new ArrayList<Object>();
|
||||
for (Object arg : args) {
|
||||
if (arg == null) {
|
||||
continue;
|
||||
}
|
||||
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
||||
|| arg instanceof HttpSession) {
|
||||
continue;
|
||||
}
|
||||
filtered.add(arg);
|
||||
}
|
||||
if (filtered.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return SensitiveDataMasker.maskObject(filtered);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入响应结果(脱敏 → 截断 → GZIP)。
|
||||
*/
|
||||
private void fillResponse(SysOapiLog entity, Object result, boolean saveResponse, int maxResponseSize) {
|
||||
if (!saveResponse || result == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String resp = SensitiveDataMasker.maskObject(result);
|
||||
if (resp == null) {
|
||||
return;
|
||||
}
|
||||
if (resp.length() > maxResponseSize) {
|
||||
resp = resp.substring(0, maxResponseSize) + "...[截断]";
|
||||
}
|
||||
entity.setResponseData(GzipUtil.compress(resp));
|
||||
} catch (Exception e) {
|
||||
log.warn("开放接口日志响应序列化失败, uri={}", entity.getRequestUri(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attrs != null ? attrs.getRequest() : null;
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLen) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return text.length() > maxLen ? text.substring(0, maxLen) : text;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ public class OperationLogAspect {
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
@Around("execution(* com.yunhai.userbase.controller..*.*(..))")
|
||||
@Around("execution(* com.yunhai.userbase.controller..*.*(..))"
|
||||
+ " && !execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
// 排除日志查看接口自身
|
||||
if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.yunhai.userbase.common.annotation;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 开放接口日志注解。<br>
|
||||
* 标注在 {@code controller.oapi} 方法上:
|
||||
* <ul>
|
||||
* <li>POST 写操作无需标注,OapiLogAspect 自动全量记录;</li>
|
||||
* <li>GET 默认不记录(高频查询),标注本注解可强制记录(或开启配置 oapi.log-get=true);</li>
|
||||
* <li>value 提供中文操作描述,落入 operation_desc 字段。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface OapiLog {
|
||||
|
||||
/**
|
||||
* 操作描述,可选。
|
||||
*/
|
||||
String value() default "";
|
||||
|
||||
/**
|
||||
* 是否保存响应结果(GZIP 压缩后入库)。
|
||||
*/
|
||||
boolean saveResponse() default true;
|
||||
|
||||
/**
|
||||
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
|
||||
*/
|
||||
int maxResponseSize() default 10240;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
/**
|
||||
* 异步线程池配置。<br>
|
||||
* 日志类异步落库统一走有界池 {@code oapiLogExecutor}:<br>
|
||||
* - core 4 / max 16 / queue 1000,高并发下不会为每条日志 new 一个线程;<br>
|
||||
* - DiscardPolicy 丢弃策略:池满时直接丢弃新任务,日志宁可丢失也不拖垮/阻塞主流程。<br>
|
||||
* 管理端操作日志(SysOperationLogServiceImpl.saveAsync)与开放接口日志
|
||||
* (SysOapiLogServiceImpl.saveAsync)均绑定本池。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Configuration
|
||||
public class AsyncConfig {
|
||||
|
||||
/**
|
||||
* 日志异步落库线程池(有界)。
|
||||
*/
|
||||
@Bean("oapiLogExecutor")
|
||||
public Executor oapiLogExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(16);
|
||||
executor.setQueueCapacity(1000);
|
||||
executor.setThreadNamePrefix("oplog-");
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.yunhai.userbase.controller.api;
|
||||
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogListVo;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogQuery;
|
||||
import com.yunhai.userbase.service.ISysOapiLogService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 开放接口日志查询与下载接口。<br>
|
||||
* 本控制器位于 {@code controller.api} 包,不会被 OapiLogAspect(切 oapi 包)拦截。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/oapilog/")
|
||||
public class SysOapiLogController {
|
||||
|
||||
@Autowired
|
||||
private ISysOapiLogService sysOapiLogService;
|
||||
|
||||
/**
|
||||
* 分页查询开放接口日志列表(不含响应结果)。
|
||||
*/
|
||||
@Operation(summary = "开放接口日志分页查询", operationId = "oapiLogList")
|
||||
@GetMapping("/List")
|
||||
public BasePageResult<SysOapiLogListVo> list(SysOapiLogQuery query, BasePageQuery pageQuery) {
|
||||
return sysOapiLogService.pageQuery(query, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。<br>
|
||||
* 与 {@code SysOperationLogController.Download} 一致的 octet-stream + attachment 写法。<br>
|
||||
* 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。
|
||||
*/
|
||||
@Operation(summary = "下载开放接口日志响应原文", operationId = "oapiLogDownload")
|
||||
@GetMapping("/Download/{id}")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable Long id) {
|
||||
String data = sysOapiLogService.getResponseData(id);
|
||||
if (data == null) {
|
||||
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
|
||||
}
|
||||
try {
|
||||
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8));
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=" + new String(("response-" + id + ".txt").getBytes("gbk"), "iso8859-1"));
|
||||
headers.add("Pragma", "no-cache");
|
||||
headers.add("Expires", "0");
|
||||
return ResponseEntity.ok().headers(headers)
|
||||
.contentType(MediaType.parseMediaType("application/octet-stream"))
|
||||
.body(new InputStreamResource(bis));
|
||||
} catch (Exception e) {
|
||||
log.error("开放接口日志响应结果下载失败, id={}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import com.yunhai.platform.utils.enums.ClientEnum;
|
||||
import com.yunhai.platform.utils.enums.IEnum;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||
import com.yunhai.userbase.service.IAppCategoryService;
|
||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -25,6 +30,30 @@ public class AppController {
|
||||
@Autowired
|
||||
private IAppCategoryService appCategoryService;
|
||||
|
||||
@Autowired
|
||||
private IApplicationInfoService applicationInfoService;
|
||||
|
||||
/**
|
||||
* 按主键 ID 查询应用信息(带缓存)。<br>
|
||||
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
|
||||
* 返回 {@link ApplicationInfoVo} 并清空 appSecret,与 list 接口一致不透出签名密钥。
|
||||
*
|
||||
* @param appId 应用主键 ID(必填)
|
||||
* @return 应用信息视图;不存在时返回 null
|
||||
*/
|
||||
@GetMapping("{appId}")
|
||||
public ApplicationInfoVo byId(@PathVariable Integer appId) {
|
||||
ApplicationInfo info = applicationInfoService.getById(appId);
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
ApplicationInfoVo vo = new ApplicationInfoVo();
|
||||
BeanUtils.copyProperties(info, vo);
|
||||
// 不透出签名密钥(与 list 契约一致)
|
||||
vo.setAppSecret(null);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。<br>
|
||||
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
/**
|
||||
* 开放接口调用日志实体。<br>
|
||||
* 记录 yh-app-auth 签名调用方(YschBaseOpenApi 门面背后的 /oapi/** 端点)的操作,
|
||||
* 身份主体为「调用方应用」(callerAppKey/callerAppName),区别于管理端 sys_operation_log 的登录用户维度。<br>
|
||||
* 请求参数与响应均已脱敏后入库(敏感字段见 SensitiveDataMasker)。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sys_oapi_log", indexes = {
|
||||
@Index(name = "idx_create_time", columnList = "create_time"),
|
||||
@Index(name = "idx_appkey_time", columnList = "caller_app_key, create_time"),
|
||||
@Index(name = "idx_uri_time", columnList = "request_uri, create_time"),
|
||||
@Index(name = "idx_level_time", columnList = "log_level, create_time"),
|
||||
@Index(name = "idx_ip_time", columnList = "request_ip, create_time")
|
||||
})
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
public class SysOapiLog 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;
|
||||
|
||||
/**
|
||||
* 调用方应用 appKey(验签后写入 request attribute 的 AppAuthFilter.APP_AUTH_ATTR)
|
||||
*/
|
||||
@Column(name = "caller_app_key", nullable = false)
|
||||
private Integer callerAppKey;
|
||||
|
||||
/**
|
||||
* 调用方应用名(application_info 解析;解析失败记 appKey 原值)
|
||||
*/
|
||||
@Column(name = "caller_app_name", length = 128)
|
||||
private String callerAppName;
|
||||
|
||||
/**
|
||||
* 请求IP
|
||||
*/
|
||||
@Column(name = "request_ip", nullable = false, length = 64)
|
||||
private String requestIp;
|
||||
|
||||
/**
|
||||
* 请求路径
|
||||
*/
|
||||
@Column(name = "request_uri", nullable = false, length = 512)
|
||||
private String requestUri;
|
||||
|
||||
/**
|
||||
* 请求方法 GET/POST/PUT/DELETE
|
||||
*/
|
||||
@Column(name = "request_method", nullable = false, length = 16)
|
||||
private String requestMethod;
|
||||
|
||||
/**
|
||||
* 操作类型 DELETE/CREATE/UPDATE/QUERY
|
||||
*/
|
||||
@Column(name = "operation_type", nullable = false, length = 32)
|
||||
private String operationType;
|
||||
|
||||
/**
|
||||
* 类名.方法名
|
||||
*/
|
||||
@Column(name = "class_method", nullable = false, length = 512)
|
||||
private String classMethod;
|
||||
|
||||
/**
|
||||
* 操作描述(@OapiLog 注解 value 提供,可选)
|
||||
*/
|
||||
@Column(name = "operation_desc", length = 255)
|
||||
private String operationDesc;
|
||||
|
||||
/**
|
||||
* 请求参数JSON(脱敏后)
|
||||
*/
|
||||
@Column(name = "request_params", columnDefinition = "TEXT")
|
||||
private String requestParams;
|
||||
|
||||
/**
|
||||
* 响应结果(GZIP 压缩,脱敏后)
|
||||
*/
|
||||
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||
private byte[] responseData;
|
||||
|
||||
/**
|
||||
* HTTP状态码
|
||||
*/
|
||||
@Column(name = "status_code", nullable = false)
|
||||
private Integer statusCode;
|
||||
|
||||
/**
|
||||
* 耗时(ms)
|
||||
*/
|
||||
@Column(name = "cost_time", nullable = false)
|
||||
private Long costTime;
|
||||
|
||||
/**
|
||||
* 调用方UA
|
||||
*/
|
||||
@Column(name = "user_agent", length = 512)
|
||||
private String userAgent;
|
||||
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
@Column(name = "error_msg", columnDefinition = "TEXT")
|
||||
private String errorMsg;
|
||||
|
||||
/**
|
||||
* 调用时间
|
||||
*/
|
||||
@CreationTimestamp
|
||||
@Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.yunhai.userbase.model.oapilog;
|
||||
|
||||
import com.alibaba.fastjson.annotation.JSONField;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 开放接口日志列表项视图。<br>
|
||||
* 不含 response_data(响应结果),列表不展示,需通过下载接口单独获取。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Data
|
||||
public class SysOapiLogListVo {
|
||||
|
||||
/** 主键 */
|
||||
private Long id;
|
||||
/** 日志级别 1-重要 2-次要 3-一般 */
|
||||
private Integer logLevel;
|
||||
/** 调用方应用 appKey */
|
||||
private Integer callerAppKey;
|
||||
/** 调用方应用名 */
|
||||
private String callerAppName;
|
||||
/** 请求IP */
|
||||
private String requestIp;
|
||||
/** 请求路径 */
|
||||
private String requestUri;
|
||||
/** 请求方法 GET/POST/PUT/DELETE */
|
||||
private String requestMethod;
|
||||
/** 操作类型 DELETE/CREATE/UPDATE/QUERY */
|
||||
private String operationType;
|
||||
/** 类名.方法名 */
|
||||
private String classMethod;
|
||||
/** 操作描述 */
|
||||
private String operationDesc;
|
||||
/** 请求参数JSON(脱敏后) */
|
||||
private String requestParams;
|
||||
/** HTTP 状态码 */
|
||||
private Integer statusCode;
|
||||
/** 耗时(ms) */
|
||||
private Long costTime;
|
||||
/** 调用方UA */
|
||||
private String userAgent;
|
||||
/** 错误信息 */
|
||||
private String errorMsg;
|
||||
/** 调用时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 全参构造,顺序需与 {@code SysOapiLog} 的列表投影字段一致。<br>
|
||||
* 用于 JPA Criteria {@code cb.construct} 投影查询。<br>
|
||||
*/
|
||||
public SysOapiLogListVo(Long id, Integer logLevel, Integer callerAppKey, String callerAppName,
|
||||
String requestIp, String requestUri, String requestMethod,
|
||||
String operationType, String classMethod, String operationDesc,
|
||||
String requestParams, Integer statusCode, Long costTime,
|
||||
String userAgent, String errorMsg, LocalDateTime createTime) {
|
||||
this.id = id;
|
||||
this.logLevel = logLevel;
|
||||
this.callerAppKey = callerAppKey;
|
||||
this.callerAppName = callerAppName;
|
||||
this.requestIp = requestIp;
|
||||
this.requestUri = requestUri;
|
||||
this.requestMethod = requestMethod;
|
||||
this.operationType = operationType;
|
||||
this.classMethod = classMethod;
|
||||
this.operationDesc = operationDesc;
|
||||
this.requestParams = requestParams;
|
||||
this.statusCode = statusCode;
|
||||
this.costTime = costTime;
|
||||
this.userAgent = userAgent;
|
||||
this.errorMsg = errorMsg;
|
||||
this.createTime = createTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.yunhai.userbase.model.oapilog;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 开放接口日志查询条件。<br>
|
||||
* 覆盖调用方应用、URI、级别、IP、时间等高频查询场景,与表索引前缀对齐。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Data
|
||||
public class SysOapiLogQuery {
|
||||
|
||||
/** 日志级别(1-重要 2-次要 3-一般) */
|
||||
private Integer logLevel;
|
||||
|
||||
/** 调用方应用 appKey(等值) */
|
||||
private Integer callerAppKey;
|
||||
|
||||
/** 调用方应用名(模糊) */
|
||||
private String callerAppName;
|
||||
|
||||
/** 请求路径(模糊) */
|
||||
private String requestUri;
|
||||
|
||||
/** 请求IP(模糊) */
|
||||
private String requestIp;
|
||||
|
||||
/** 操作类型 DELETE/CREATE/UPDATE/QUERY */
|
||||
private String operationType;
|
||||
|
||||
/** HTTP 状态码 */
|
||||
private Integer statusCode;
|
||||
|
||||
/**
|
||||
* 成功/失败筛选。<br>
|
||||
* true=仅 HTTP 200(成功),false=非 200(失败)。
|
||||
*/
|
||||
private Boolean success;
|
||||
|
||||
/**
|
||||
* 关键字,同时匹配请求路径(requestUri)与应用名(callerAppName)。<br>
|
||||
* 前端单输入框搜索这两个字段。
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
/** 调用起始时间(含) */
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
/** 调用结束时间(含) */
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime endTime;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.userbase.repository;
|
||||
|
||||
import com.yunhai.userbase.entity.SysOapiLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 开放接口调用日志仓储。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
public interface SysOapiLogRepository extends JpaRepository<SysOapiLog, Long>,
|
||||
JpaSpecificationExecutor<SysOapiLog> {
|
||||
|
||||
/**
|
||||
* 按日志级别 + 创建时间批量删除(单条 DELETE 语句,不先加载实体)。<br>
|
||||
* 用于定时清理:一般日志保留 12 个月,重要/次要日志保留 36 个月。<br>
|
||||
*
|
||||
* @param logLevel 日志级别编码
|
||||
* @param createTime 截止时间(早于该时间的记录被删除)
|
||||
* @return 删除条数
|
||||
*/
|
||||
long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime createTime);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.userbase.service;
|
||||
|
||||
import com.yunhai.userbase.entity.SysOapiLog;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogListVo;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogQuery;
|
||||
|
||||
/**
|
||||
* 开放接口调用日志业务接口。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
public interface ISysOapiLogService {
|
||||
|
||||
/**
|
||||
* 异步保存开放接口日志。<br>
|
||||
* 由 OapiLogAspect 在 finally 中调用,失败仅记录日志,不影响主业务。<br>
|
||||
*
|
||||
* @param entity 日志实体
|
||||
*/
|
||||
void saveAsync(SysOapiLog entity);
|
||||
|
||||
/**
|
||||
* 分页查询开放接口日志列表。<br>
|
||||
* 列表不含响应结果(response_data),避免加载 MEDIUMBLOB 影响性能。<br>
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @param pageQuery 分页参数(pageIndex 从 1 起)
|
||||
* @return 分页结果
|
||||
*/
|
||||
BasePageResult<SysOapiLogListVo> pageQuery(SysOapiLogQuery query, BasePageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 获取某条日志的响应结果(解压后原文)。<br>
|
||||
*
|
||||
* @param id 日志ID
|
||||
* @return 解压后的响应字符串,无记录或无响应数据返回 null
|
||||
*/
|
||||
String getResponseData(Long id);
|
||||
|
||||
/**
|
||||
* 清理过期日志。<br>
|
||||
* 一般日志(level=3)保留 12 个月,重要(level=1)/次要(level=2)保留 36 个月。<br>
|
||||
*
|
||||
* @return 删除条数
|
||||
*/
|
||||
long cleanupExpired();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.yunhai.userbase.service.impl;
|
||||
|
||||
import com.yunhai.userbase.entity.SysOapiLog;
|
||||
import com.yunhai.userbase.model.BasePageQuery;
|
||||
import com.yunhai.userbase.model.BasePageResult;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogListVo;
|
||||
import com.yunhai.userbase.model.oapilog.SysOapiLogQuery;
|
||||
import com.yunhai.userbase.repository.SysOapiLogRepository;
|
||||
import com.yunhai.userbase.service.ISysOapiLogService;
|
||||
import com.yunhai.userbase.utils.GzipUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import javax.persistence.TypedQuery;
|
||||
import javax.persistence.criteria.CriteriaBuilder;
|
||||
import javax.persistence.criteria.CriteriaQuery;
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import javax.persistence.criteria.Root;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 开放接口调用日志业务实现。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SysOapiLogServiceImpl implements ISysOapiLogService {
|
||||
|
||||
/** 一般日志(level=3)保留时长:一年(12 个月)。 */
|
||||
private static final int GENERAL_RETENTION_MONTHS = 12;
|
||||
/** 重要(level=1)/次要(level=2)日志保留时长:三年(36 个月)。 */
|
||||
private static final int IMPORTANT_RETENTION_MONTHS = 36;
|
||||
|
||||
@Autowired
|
||||
private SysOapiLogRepository sysOapiLogRepository;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
|
||||
@Override
|
||||
@Async("oapiLogExecutor")
|
||||
public void saveAsync(SysOapiLog entity) {
|
||||
try {
|
||||
sysOapiLogRepository.save(entity);
|
||||
} catch (Exception e) {
|
||||
// 日志落库失败绝不抛回主流程,仅记录
|
||||
log.error("开放接口日志异步落库失败, uri={}, method={}",
|
||||
entity.getRequestUri(), entity.getRequestMethod(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BasePageResult<SysOapiLogListVo> pageQuery(SysOapiLogQuery query, BasePageQuery pageQuery) {
|
||||
int pageIndex = Math.max(pageQuery.getPageIndex(), 1);
|
||||
int pageSize = Math.max(pageQuery.getPageSize(), 1);
|
||||
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
// 列表投影:显式选择字段,不包含 response_data,避免加载 MEDIUMBLOB
|
||||
CriteriaQuery<SysOapiLogListVo> cq = cb.createQuery(SysOapiLogListVo.class);
|
||||
Root<SysOapiLog> root = cq.from(SysOapiLog.class);
|
||||
cq.select(cb.construct(SysOapiLogListVo.class,
|
||||
root.get("id"), root.get("logLevel"), root.get("callerAppKey"), root.get("callerAppName"),
|
||||
root.get("requestIp"), root.get("requestUri"), root.get("requestMethod"),
|
||||
root.get("operationType"), root.get("classMethod"), root.get("operationDesc"),
|
||||
root.get("requestParams"), root.get("statusCode"), root.get("costTime"),
|
||||
root.get("userAgent"), root.get("errorMsg"), root.get("createTime")));
|
||||
List<Predicate> predicates = buildPredicates(cb, root, query);
|
||||
cq.where(predicates.toArray(new Predicate[0]));
|
||||
cq.orderBy(cb.desc(root.get("createTime")));
|
||||
|
||||
// 总数查询
|
||||
CriteriaQuery<Long> countCq = cb.createQuery(Long.class);
|
||||
Root<SysOapiLog> countRoot = countCq.from(SysOapiLog.class);
|
||||
countCq.select(cb.count(countRoot));
|
||||
countCq.where(buildPredicates(cb, countRoot, query).toArray(new Predicate[0]));
|
||||
long total = em.createQuery(countCq).getSingleResult();
|
||||
|
||||
TypedQuery<SysOapiLogListVo> typed = em.createQuery(cq);
|
||||
typed.setFirstResult((pageIndex - 1) * pageSize);
|
||||
typed.setMaxResults(pageSize);
|
||||
List<SysOapiLogListVo> content = typed.getResultList();
|
||||
|
||||
int totalPages = (int) Math.ceil((double) total / pageSize);
|
||||
boolean hasNext = pageIndex < totalPages;
|
||||
|
||||
return new BasePageResult<>(content, (int) total, pageIndex, pageSize, totalPages, hasNext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getResponseData(Long id) {
|
||||
SysOapiLog entity = sysOapiLogRepository.findById(id).orElse(null);
|
||||
if (entity == null || entity.getResponseData() == null) {
|
||||
return null;
|
||||
}
|
||||
return GzipUtil.decompress(entity.getResponseData());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public long cleanupExpired() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime generalDeadline = now.minusMonths(GENERAL_RETENTION_MONTHS);
|
||||
LocalDateTime importantDeadline = now.minusMonths(IMPORTANT_RETENTION_MONTHS);
|
||||
// 一般日志(level=3)保留一年(12个月);重要(level=1)/次要(level=2)保留三年(36个月)
|
||||
long deleted = sysOapiLogRepository.deleteByLogLevelAndCreateTimeBefore(3, generalDeadline)
|
||||
+ sysOapiLogRepository.deleteByLogLevelAndCreateTimeBefore(2, importantDeadline)
|
||||
+ sysOapiLogRepository.deleteByLogLevelAndCreateTimeBefore(1, importantDeadline);
|
||||
log.info("开放接口日志清理完成, 删除{}条(一般截止={}, 重要/次要截止={})",
|
||||
deleted, generalDeadline, importantDeadline);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建动态查询条件。<br>
|
||||
* 模糊字段使用 LIKE,时间使用范围,其余等值匹配。<br>
|
||||
*
|
||||
* @param cb 条件构建器
|
||||
* @param root 根路径
|
||||
* @param query 查询条件
|
||||
* @return 谓词列表
|
||||
*/
|
||||
private List<Predicate> buildPredicates(CriteriaBuilder cb, Root<SysOapiLog> root,
|
||||
SysOapiLogQuery query) {
|
||||
List<Predicate> predicates = new ArrayList<Predicate>();
|
||||
if (query == null) {
|
||||
return predicates;
|
||||
}
|
||||
if (query.getLogLevel() != null) {
|
||||
predicates.add(cb.equal(root.get("logLevel"), query.getLogLevel()));
|
||||
}
|
||||
if (query.getCallerAppKey() != null) {
|
||||
predicates.add(cb.equal(root.get("callerAppKey"), query.getCallerAppKey()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getCallerAppName())) {
|
||||
predicates.add(cb.like(root.get("callerAppName"), "%" + query.getCallerAppName() + "%"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getRequestUri())) {
|
||||
predicates.add(cb.like(root.get("requestUri"), "%" + query.getRequestUri() + "%"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getRequestIp())) {
|
||||
predicates.add(cb.like(root.get("requestIp"), "%" + query.getRequestIp() + "%"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getOperationType())) {
|
||||
predicates.add(cb.equal(root.get("operationType"), query.getOperationType()));
|
||||
}
|
||||
if (query.getStatusCode() != null) {
|
||||
predicates.add(cb.equal(root.get("statusCode"), query.getStatusCode()));
|
||||
}
|
||||
if (query.getSuccess() != null) {
|
||||
if (Boolean.TRUE.equals(query.getSuccess())) {
|
||||
predicates.add(cb.equal(root.get("statusCode"), 200));
|
||||
} else {
|
||||
predicates.add(cb.notEqual(root.get("statusCode"), 200));
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(query.getKeyword())) {
|
||||
String kw = "%" + query.getKeyword().trim() + "%";
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("requestUri"), kw),
|
||||
cb.like(root.get("callerAppName"), kw)));
|
||||
}
|
||||
if (query.getStartTime() != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime()));
|
||||
}
|
||||
if (query.getEndTime() != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime()));
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public class SysOperationLogServiceImpl implements ISysOperationLogService {
|
||||
private EntityManager em;
|
||||
|
||||
@Override
|
||||
@Async
|
||||
@Async("oapiLogExecutor")
|
||||
public void saveAsync(SysOperationLog entity) {
|
||||
try {
|
||||
sysOperationLogRepository.save(entity);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yunhai.userbase.task;
|
||||
|
||||
import com.yunhai.userbase.service.ISysOapiLogService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 开放接口日志定时清理任务。<br>
|
||||
* 每日凌晨 3 点执行:一般日志(level=3)保留一年、重要(level=1)/次要(level=2)日志保留三年。<br>
|
||||
* 与管理端 OperationLogCleanupTask 同范式(@EnableScheduling 已在 UserBaseApplication 开启)。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class OapiLogCleanupTask {
|
||||
|
||||
@Autowired
|
||||
private ISysOapiLogService sysOapiLogService;
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * ?")
|
||||
public void run() {
|
||||
try {
|
||||
long deleted = sysOapiLogService.cleanupExpired();
|
||||
log.info("开放接口日志定时清理完成, 删除{}条", deleted);
|
||||
} catch (Exception e) {
|
||||
log.error("开放接口日志定时清理失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.yunhai.userbase.utils;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 敏感字段脱敏工具。<br>
|
||||
* 开放接口日志在入库前,请求参数与响应必须经过本工具:命中敏感字段名(忽略大小写)
|
||||
* 的键值一律替换为 {@code ***},防止明文密码/密钥/令牌写入日志库。<br>
|
||||
* 实现基于 fastjson 递归遍历(不引新依赖);非 JSON 文本原样返回并打 warn 日志。<br>
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/20
|
||||
*/
|
||||
@Slf4j
|
||||
public class SensitiveDataMasker {
|
||||
|
||||
/**
|
||||
* 敏感字段名名单(按小写匹配,命中即掩码)。
|
||||
*/
|
||||
private static final List<String> SENSITIVE_FIELDS = Arrays.asList(
|
||||
"password", "oldpassword", "newpassword", "confirmpassword",
|
||||
"appsecret", "secret", "token", "accesskey", "accesskeyid", "accesskeysecret");
|
||||
|
||||
/**
|
||||
* 掩码占位符。
|
||||
*/
|
||||
public static final String MASK = "***";
|
||||
|
||||
private SensitiveDataMasker() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对任意对象做脱敏:先序列化为 JSON 字符串,再递归掩码敏感字段。<br>
|
||||
* 序列化失败或对象本身非 JSON 结构时,返回 null(由调用方决定降级处理)。
|
||||
*
|
||||
* @param source 待脱敏对象(可为 null)
|
||||
* @return 脱敏后的 JSON 字符串;输入为 null 返回 null
|
||||
*/
|
||||
public static String maskObject(Object source) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
String json = JSON.toJSONString(source);
|
||||
if (json == null || json.isEmpty()) {
|
||||
return json;
|
||||
}
|
||||
return maskJson(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 JSON 字符串递归脱敏(含嵌套对象/数组)。<br>
|
||||
* 解析失败(非 JSON)时原样返回并 warn。
|
||||
*
|
||||
* @param json JSON 字符串(可为 null/空)
|
||||
* @return 脱敏后的 JSON 字符串;输入为 null 返回 null
|
||||
*/
|
||||
public static String maskJson(String json) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Object parsed = JSON.parse(json);
|
||||
if (parsed == null) {
|
||||
return json;
|
||||
}
|
||||
maskValue(parsed);
|
||||
return JSON.toJSONString(parsed);
|
||||
} catch (Exception e) {
|
||||
// 非 JSON 文本(如错误消息)不脱敏,原样返回
|
||||
log.warn("敏感字段脱敏跳过(非 JSON), len={}", json.length());
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归遍历 JSON 结构,命中敏感字段名的键值替换为掩码。
|
||||
*/
|
||||
private static void maskValue(Object value) {
|
||||
if (value instanceof JSONObject) {
|
||||
JSONObject obj = (JSONObject) value;
|
||||
for (String key : obj.keySet()) {
|
||||
Object v = obj.get(key);
|
||||
if (v instanceof JSONObject || v instanceof JSONArray) {
|
||||
maskValue(v);
|
||||
} else if (isSensitive(key)) {
|
||||
obj.put(key, MASK);
|
||||
}
|
||||
}
|
||||
} else if (value instanceof JSONArray) {
|
||||
JSONArray arr = (JSONArray) value;
|
||||
for (Object item : arr) {
|
||||
maskValue(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段名是否命中敏感名单(忽略大小写)。
|
||||
*/
|
||||
private static boolean isSensitive(String key) {
|
||||
if (key == null || key.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String lower = key.toLowerCase(Locale.ROOT);
|
||||
for (String field : SENSITIVE_FIELDS) {
|
||||
if (lower.contains(field)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user