diff --git a/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java b/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java new file mode 100644 index 0000000..965e96e --- /dev/null +++ b/src/main/java/com/yunhai/userbase/aspect/OapiLogAspect.java @@ -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/**)调用日志切面。
+ * 拦截 {@code controller.oapi} 包下所有方法,规则:
+ * - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;
+ * - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;
+ * - 排除 {@code SignKeyInternalController}(/oapi/internal,响应含 appSecret 明文,绝不落库);
+ * - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;
+ * - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。
+ * + * @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,并映射应用名。
+ * 无 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 filtered = new ArrayList(); + 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java b/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java index 980604e..99afc9b 100644 --- a/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java +++ b/src/main/java/com/yunhai/userbase/aspect/OperationLogAspect.java @@ -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())) { diff --git a/src/main/java/com/yunhai/userbase/common/annotation/OapiLog.java b/src/main/java/com/yunhai/userbase/common/annotation/OapiLog.java new file mode 100644 index 0000000..f34290c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/annotation/OapiLog.java @@ -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; + +/** + * 开放接口日志注解。
+ * 标注在 {@code controller.oapi} 方法上: + * + * + * @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; +} diff --git a/src/main/java/com/yunhai/userbase/config/AsyncConfig.java b/src/main/java/com/yunhai/userbase/config/AsyncConfig.java new file mode 100644 index 0000000..bf8873a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/AsyncConfig.java @@ -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; + +/** + * 异步线程池配置。
+ * 日志类异步落库统一走有界池 {@code oapiLogExecutor}:
+ * - core 4 / max 16 / queue 1000,高并发下不会为每条日志 new 一个线程;
+ * - DiscardPolicy 丢弃策略:池满时直接丢弃新任务,日志宁可丢失也不拖垮/阻塞主流程。
+ * 管理端操作日志(SysOperationLogServiceImpl.saveAsync)与开放接口日志 + * (SysOapiLogServiceImpl.saveAsync)均绑定本池。
+ * + * @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; + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java b/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java new file mode 100644 index 0000000..46ffd0f --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java @@ -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; + +/** + * 开放接口日志查询与下载接口。
+ * 本控制器位于 {@code controller.api} 包,不会被 OapiLogAspect(切 oapi 包)拦截。
+ * + * @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 list(SysOapiLogQuery query, BasePageQuery pageQuery) { + return sysOapiLogService.pageQuery(query, pageQuery); + } + + /** + * 下载某条日志的响应结果(GZIP 解压后原文,文本文件)。
+ * 与 {@code SysOperationLogController.Download} 一致的 octet-stream + attachment 写法。
+ * 若该日志未记录响应数据,返回一段说明文本文件而非 404,便于排查。 + */ + @Operation(summary = "下载开放接口日志响应原文", operationId = "oapiLogDownload") + @GetMapping("/Download/{id}") + public ResponseEntity 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(); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index 37ea302..efc3ebb 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -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 查询应用信息(带缓存)。
+ * 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。
+ * 返回 {@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; + } + /** * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用), diff --git a/src/main/java/com/yunhai/userbase/entity/SysOapiLog.java b/src/main/java/com/yunhai/userbase/entity/SysOapiLog.java new file mode 100644 index 0000000..5ab18d6 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysOapiLog.java @@ -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; + +/** + * 开放接口调用日志实体。
+ * 记录 yh-app-auth 签名调用方(YschBaseOpenApi 门面背后的 /oapi/** 端点)的操作, + * 身份主体为「调用方应用」(callerAppKey/callerAppName),区别于管理端 sys_operation_log 的登录用户维度。
+ * 请求参数与响应均已脱敏后入库(敏感字段见 SensitiveDataMasker)。
+ * + * @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; + +} diff --git a/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java new file mode 100644 index 0000000..3da66fc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogListVo.java @@ -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; + +/** + * 开放接口日志列表项视图。
+ * 不含 response_data(响应结果),列表不展示,需通过下载接口单独获取。
+ * + * @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} 的列表投影字段一致。
+ * 用于 JPA Criteria {@code cb.construct} 投影查询。
+ */ + 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogQuery.java b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogQuery.java new file mode 100644 index 0000000..cfbad9b --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/oapilog/SysOapiLogQuery.java @@ -0,0 +1,59 @@ +package com.yunhai.userbase.model.oapilog; + +import lombok.Data; + +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +/** + * 开放接口日志查询条件。
+ * 覆盖调用方应用、URI、级别、IP、时间等高频查询场景,与表索引前缀对齐。
+ * + * @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; + + /** + * 成功/失败筛选。
+ * true=仅 HTTP 200(成功),false=非 200(失败)。 + */ + private Boolean success; + + /** + * 关键字,同时匹配请求路径(requestUri)与应用名(callerAppName)。
+ * 前端单输入框搜索这两个字段。 + */ + 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; +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysOapiLogRepository.java b/src/main/java/com/yunhai/userbase/repository/SysOapiLogRepository.java new file mode 100644 index 0000000..7bcf205 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysOapiLogRepository.java @@ -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; + +/** + * 开放接口调用日志仓储。
+ * + * @author chenx + * @date 2026/8/20 + */ +public interface SysOapiLogRepository extends JpaRepository, + JpaSpecificationExecutor { + + /** + * 按日志级别 + 创建时间批量删除(单条 DELETE 语句,不先加载实体)。
+ * 用于定时清理:一般日志保留 12 个月,重要/次要日志保留 36 个月。
+ * + * @param logLevel 日志级别编码 + * @param createTime 截止时间(早于该时间的记录被删除) + * @return 删除条数 + */ + long deleteByLogLevelAndCreateTimeBefore(Integer logLevel, LocalDateTime createTime); +} diff --git a/src/main/java/com/yunhai/userbase/service/ISysOapiLogService.java b/src/main/java/com/yunhai/userbase/service/ISysOapiLogService.java new file mode 100644 index 0000000..293c7d4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/ISysOapiLogService.java @@ -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; + +/** + * 开放接口调用日志业务接口。
+ * + * @author chenx + * @date 2026/8/20 + */ +public interface ISysOapiLogService { + + /** + * 异步保存开放接口日志。
+ * 由 OapiLogAspect 在 finally 中调用,失败仅记录日志,不影响主业务。
+ * + * @param entity 日志实体 + */ + void saveAsync(SysOapiLog entity); + + /** + * 分页查询开放接口日志列表。
+ * 列表不含响应结果(response_data),避免加载 MEDIUMBLOB 影响性能。
+ * + * @param query 查询条件 + * @param pageQuery 分页参数(pageIndex 从 1 起) + * @return 分页结果 + */ + BasePageResult pageQuery(SysOapiLogQuery query, BasePageQuery pageQuery); + + /** + * 获取某条日志的响应结果(解压后原文)。
+ * + * @param id 日志ID + * @return 解压后的响应字符串,无记录或无响应数据返回 null + */ + String getResponseData(Long id); + + /** + * 清理过期日志。
+ * 一般日志(level=3)保留 12 个月,重要(level=1)/次要(level=2)保留 36 个月。
+ * + * @return 删除条数 + */ + long cleanupExpired(); +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java new file mode 100644 index 0000000..542b9c9 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java @@ -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; + +/** + * 开放接口调用日志业务实现。
+ * + * @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 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 cq = cb.createQuery(SysOapiLogListVo.class); + Root 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 predicates = buildPredicates(cb, root, query); + cq.where(predicates.toArray(new Predicate[0])); + cq.orderBy(cb.desc(root.get("createTime"))); + + // 总数查询 + CriteriaQuery countCq = cb.createQuery(Long.class); + Root 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 typed = em.createQuery(cq); + typed.setFirstResult((pageIndex - 1) * pageSize); + typed.setMaxResults(pageSize); + List 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; + } + + /** + * 构建动态查询条件。
+ * 模糊字段使用 LIKE,时间使用范围,其余等值匹配。
+ * + * @param cb 条件构建器 + * @param root 根路径 + * @param query 查询条件 + * @return 谓词列表 + */ + private List buildPredicates(CriteriaBuilder cb, Root root, + SysOapiLogQuery query) { + List predicates = new ArrayList(); + 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; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java b/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java index 4e458a4..72076b4 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysOperationLogServiceImpl.java @@ -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); diff --git a/src/main/java/com/yunhai/userbase/task/OapiLogCleanupTask.java b/src/main/java/com/yunhai/userbase/task/OapiLogCleanupTask.java new file mode 100644 index 0000000..98bc783 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/task/OapiLogCleanupTask.java @@ -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; + +/** + * 开放接口日志定时清理任务。
+ * 每日凌晨 3 点执行:一般日志(level=3)保留一年、重要(level=1)/次要(level=2)日志保留三年。
+ * 与管理端 OperationLogCleanupTask 同范式(@EnableScheduling 已在 UserBaseApplication 开启)。
+ * + * @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); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/SensitiveDataMasker.java b/src/main/java/com/yunhai/userbase/utils/SensitiveDataMasker.java new file mode 100644 index 0000000..0f6a4dc --- /dev/null +++ b/src/main/java/com/yunhai/userbase/utils/SensitiveDataMasker.java @@ -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; + +/** + * 敏感字段脱敏工具。
+ * 开放接口日志在入库前,请求参数与响应必须经过本工具:命中敏感字段名(忽略大小写) + * 的键值一律替换为 {@code ***},防止明文密码/密钥/令牌写入日志库。
+ * 实现基于 fastjson 递归遍历(不引新依赖);非 JSON 文本原样返回并打 warn 日志。
+ * + * @author chenx + * @date 2026/8/20 + */ +@Slf4j +public class SensitiveDataMasker { + + /** + * 敏感字段名名单(按小写匹配,命中即掩码)。 + */ + private static final List SENSITIVE_FIELDS = Arrays.asList( + "password", "oldpassword", "newpassword", "confirmpassword", + "appsecret", "secret", "token", "accesskey", "accesskeyid", "accesskeysecret"); + + /** + * 掩码占位符。 + */ + public static final String MASK = "***"; + + private SensitiveDataMasker() { + } + + /** + * 对任意对象做脱敏:先序列化为 JSON 字符串,再递归掩码敏感字段。
+ * 序列化失败或对象本身非 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 字符串递归脱敏(含嵌套对象/数组)。
+ * 解析失败(非 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; + } +}