暂存提交

This commit is contained in:
2026-07-30 18:01:36 +08:00
commit 2f4e159356
368 changed files with 20370 additions and 0 deletions
+15
View File
@@ -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);
}
}
@@ -0,0 +1 @@
com.baiyue.common.audit.BaiyueAuditAutoConfiguration