添加推送日志功能
ci-userbase-service / build-and-push (push) Successful in 1m49s

This commit is contained in:
chenxing
2026-08-20 10:24:47 +08:00
parent 99414e2887
commit e001bc9d77
15 changed files with 1124 additions and 2 deletions
@@ -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;
}
}