暂存提交
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-audit</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。
|
||||
*/
|
||||
public record AuditEvent(
|
||||
String eventType, String resourceType, String resourceId, String description,
|
||||
String requestId, String traceId, Long tenantId, Long operatorTenantId,
|
||||
Long userId, String username, String appCode, String clientType,
|
||||
String httpMethod, String requestUri, String clientIp, long durationMs,
|
||||
boolean success, String errorCode, String errorMessage,
|
||||
String beforeData, String afterData, Instant occurredAt, String serviceName
|
||||
) { }
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 业务审计配置模型,控制审计开关、输出方式、远程地址、服务名和快照长度限制。
|
||||
*/
|
||||
@ConfigurationProperties("baiyue.audit")
|
||||
public class AuditProperties {
|
||||
private boolean enabled = true;
|
||||
private String endpoint;
|
||||
private int maxPayloadLength = 16000;
|
||||
private String serviceName = "unknown-service";
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public String getEndpoint() { return endpoint; }
|
||||
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
|
||||
public int getMaxPayloadLength() { return maxPayloadLength; }
|
||||
public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; }
|
||||
public String getServiceName() { return serviceName; }
|
||||
public void setServiceName(String serviceName) { this.serviceName = serviceName; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
/**
|
||||
* 业务审计事件输出接口,用于解耦审计事件采集与日志或远程服务等落地方式。
|
||||
*/
|
||||
public interface AuditSink {
|
||||
void publish(AuditEvent event);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(AuditProperties.class)
|
||||
@ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class BaiyueAuditAutoConfiguration {
|
||||
@Bean @ConditionalOnMissingBean AuditSink auditSink(RestClient.Builder builder, AuditProperties p) {
|
||||
return p.getEndpoint() == null || p.getEndpoint().isBlank() ? new LoggingAuditSink() : new HttpAuditSink(builder, p.getEndpoint());
|
||||
}
|
||||
@Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); }
|
||||
@Bean BusinessAuditAspect businessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, AuditProperties p) { return new BusinessAuditAspect(sink, sanitizer, p.getServiceName()); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 业务审计方法注解,声明事件类型、资源标识、描述以及是否采集变更前后数据。
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface BusinessAudit {
|
||||
String event();
|
||||
String resourceType();
|
||||
String resourceId() default ""; // SpEL, e.g. #request.orderId
|
||||
String description() default "";
|
||||
boolean captureBefore() default true;
|
||||
boolean captureAfter() default true;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
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.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.expression.MethodBasedEvaluationContext;
|
||||
import org.springframework.core.DefaultParameterNameDiscoverer;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。
|
||||
*/
|
||||
@Aspect
|
||||
public class BusinessAuditAspect {
|
||||
private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class);
|
||||
private final AuditSink sink;
|
||||
private final SensitiveDataSanitizer sanitizer;
|
||||
private final String serviceName;
|
||||
private final ExpressionParser parser = new SpelExpressionParser();
|
||||
|
||||
public BusinessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, String serviceName) {
|
||||
this.sink = sink; this.sanitizer = sanitizer; this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
@Around("@annotation(audit)")
|
||||
public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable {
|
||||
long start = System.nanoTime();
|
||||
String before = audit.captureBefore() ? sanitizer.sanitize(pjp.getArgs()) : null;
|
||||
Object result = null; Throwable failure = null;
|
||||
try { result = pjp.proceed(); return result; }
|
||||
catch (Throwable ex) { failure = ex; throw ex; }
|
||||
finally {
|
||||
try { publish(pjp, audit, before, result, failure, (System.nanoTime() - start) / 1_000_000); }
|
||||
catch (RuntimeException ex) { log.warn("Audit capture failed", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(ProceedingJoinPoint pjp, BusinessAudit a, String before, Object result,
|
||||
Throwable failure, long duration) {
|
||||
HttpServletRequest req = request();
|
||||
AuthenticatedUser user = currentUser();
|
||||
String resourceId = evaluateResourceId(pjp, a.resourceId());
|
||||
sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(),
|
||||
req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req),
|
||||
user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(),
|
||||
user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(),
|
||||
user == null ? null : user.userId(), user == null ? null : user.username(),
|
||||
user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(),
|
||||
req == null ? null : req.getMethod(), req == null ? null : req.getRequestURI(),
|
||||
req == null ? null : clientIp(req), duration, failure == null,
|
||||
failure == null ? null : failure.getClass().getSimpleName(),
|
||||
failure == null ? null : failure.getMessage(), before,
|
||||
a.captureAfter() ? sanitizer.sanitize(result) : null, Instant.now(),
|
||||
serviceName));
|
||||
}
|
||||
|
||||
private HttpServletRequest request() {
|
||||
var attrs = RequestContextHolder.getRequestAttributes();
|
||||
return attrs instanceof ServletRequestAttributes s ? s.getRequest() : null;
|
||||
}
|
||||
private AuthenticatedUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null;
|
||||
}
|
||||
private String evaluateResourceId(ProceedingJoinPoint p, String expression) {
|
||||
if (expression == null || expression.isBlank()) return null;
|
||||
MethodSignature s = (MethodSignature) p.getSignature();
|
||||
var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer());
|
||||
Object v = parser.parseExpression(expression).getValue(ctx); return v == null ? null : v.toString();
|
||||
}
|
||||
private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); }
|
||||
private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } }
|
||||
private static String clientIp(HttpServletRequest r) { String x = r.getHeader("X-Forwarded-For"); return x == null ? r.getRemoteAddr() : x.split(",")[0].trim(); }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。
|
||||
*/
|
||||
public class HttpAuditSink implements AuditSink {
|
||||
private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT");
|
||||
private final RestClient client;
|
||||
private final String endpoint;
|
||||
|
||||
public HttpAuditSink(RestClient.Builder builder, String endpoint) {
|
||||
this.client = builder.build();
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
@Override public void publish(AuditEvent event) {
|
||||
try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); }
|
||||
catch (RuntimeException ex) {
|
||||
auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}",
|
||||
ex.getClass().getSimpleName(), ex.getMessage(), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 日志审计输出实现,在未配置远程审计服务时将结构化审计事件写入应用日志。
|
||||
*/
|
||||
public class LoggingAuditSink implements AuditSink {
|
||||
private static final Logger log = LoggerFactory.getLogger("BAIYUE_AUDIT");
|
||||
@Override public void publish(AuditEvent event) { log.info("audit={}", event); }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 审计数据脱敏器,在记录请求快照前递归屏蔽密码、令牌、密钥等敏感字段。
|
||||
*/
|
||||
public class SensitiveDataSanitizer {
|
||||
private static final Set<String> SENSITIVE = Set.of("password", "passwordhash", "token", "accesstoken",
|
||||
"refreshtoken", "authorization", "secret", "idcard", "bankcard", "mobile", "phone", "email");
|
||||
private final ObjectMapper mapper;
|
||||
private final int maxLength;
|
||||
|
||||
public SensitiveDataSanitizer(ObjectMapper mapper, int maxLength) {
|
||||
this.mapper = mapper;
|
||||
this.maxLength = maxLength;
|
||||
}
|
||||
|
||||
public String sanitize(Object value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
JsonNode root = mapper.valueToTree(value);
|
||||
redact(root);
|
||||
String json = mapper.writeValueAsString(root);
|
||||
return json.length() <= maxLength ? json : json.substring(0, maxLength) + "...[truncated]";
|
||||
} catch (IllegalArgumentException | JsonProcessingException e) {
|
||||
return "\"[unserializable]\"";
|
||||
}
|
||||
}
|
||||
|
||||
private void redact(JsonNode node) {
|
||||
if (node == null) return;
|
||||
if (node.isObject()) {
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
String normalized = field.getKey().replace("_", "").toLowerCase(Locale.ROOT);
|
||||
if (SENSITIVE.stream().anyMatch(normalized::contains)) {
|
||||
((ObjectNode) node).put(field.getKey(), "******");
|
||||
} else redact(field.getValue());
|
||||
}
|
||||
} else if (node.isArray()) node.forEach(this::redact);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
|
||||
Reference in New Issue
Block a user