日志记录提交
This commit is contained in:
@@ -11,5 +11,16 @@
|
||||
<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>
|
||||
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId></dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.rocketmq</groupId>
|
||||
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.time.Instant;
|
||||
* 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。
|
||||
*/
|
||||
public record AuditEvent(
|
||||
String eventType, String resourceType, String resourceId, String description,
|
||||
String eventId, 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,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.apache.rocketmq.spring.support.RocketMQHeaders;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
/** 带租约、退避、死信和自动补偿的审计 Outbox 发布器。 */
|
||||
public class AuditOutboxPublisher {
|
||||
private static final Logger log = LoggerFactory.getLogger(AuditOutboxPublisher.class);
|
||||
private static final int[] BACKOFF_SECONDS = {5, 15, 60, 300, 900};
|
||||
private final AuditOutboxRepository repository;
|
||||
private final RocketMQTemplate rocketMQ;
|
||||
private final AuditProperties properties;
|
||||
private final String owner;
|
||||
|
||||
public AuditOutboxPublisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
|
||||
AuditProperties properties, String owner) {
|
||||
this.repository = repository;
|
||||
this.rocketMQ = rocketMQ;
|
||||
this.properties = properties;
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.audit.outbox.poll-interval-ms:5000}")
|
||||
public void publish() {
|
||||
AuditProperties.Outbox config = properties.getOutbox();
|
||||
repository.releaseExpiredClaims();
|
||||
repository.compensateDead(config.getCompensationDelayMinutes(), config.getMaxCompensations());
|
||||
repository.deleteSent(config.getRetentionDays());
|
||||
for (String eventId : repository.publishableIds(config.getBatchSize())) {
|
||||
if (!repository.claim(eventId, owner, config.getLeaseSeconds())) {
|
||||
continue;
|
||||
}
|
||||
AuditOutboxRepository.OutboxRow row = repository.find(eventId);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
var message = MessageBuilder.withPayload(row.payloadJson())
|
||||
.setHeader(RocketMQHeaders.KEYS, eventId)
|
||||
.setHeader("eventId", eventId)
|
||||
.build();
|
||||
rocketMQ.syncSend(properties.getRocketmq().getTopic() + ":AUDIT", message,
|
||||
properties.getRocketmq().getSendTimeoutMs());
|
||||
repository.sent(eventId, owner);
|
||||
} catch (RuntimeException exception) {
|
||||
int index = Math.min(Math.max(row.attemptCount(), 1) - 1, BACKOFF_SECONDS.length - 1);
|
||||
repository.failed(eventId, owner, config.getMaxAttempts(), BACKOFF_SECONDS[index],
|
||||
safe(exception.getMessage()));
|
||||
log.warn("审计事件投递失败 eventId={} attempt={}", eventId, row.attemptCount(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
String message = value == null ? "RocketMQ 发送失败" : value;
|
||||
return message.length() <= 1000 ? message : message.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 业务库本地审计 Outbox 仓储。 */
|
||||
public class AuditOutboxRepository {
|
||||
@Resource
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
public void append(AuditEvent event, String payload) {
|
||||
jdbc.update("""
|
||||
INSERT INTO sys_audit_outbox
|
||||
(event_id,event_type,resource_type,resource_id,service_name,payload_json,status,
|
||||
attempt_count,compensation_count,next_retry_at,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,CAST(? AS JSON),'PENDING',0,0,CURRENT_TIMESTAMP(3),
|
||||
CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||
""", event.eventId(), event.eventType(), event.resourceType(), event.resourceId(),
|
||||
event.serviceName(), payload);
|
||||
}
|
||||
|
||||
public List<String> publishableIds(int limit) {
|
||||
return jdbc.queryForList("""
|
||||
SELECT event_id FROM sys_audit_outbox
|
||||
WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
ORDER BY next_retry_at,created_at LIMIT ?
|
||||
""", String.class, limit);
|
||||
}
|
||||
|
||||
public boolean claim(String eventId, String owner, int leaseSeconds) {
|
||||
return jdbc.update("""
|
||||
UPDATE sys_audit_outbox SET status='SENDING',lease_owner=?,
|
||||
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),
|
||||
attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE event_id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
""", owner, leaseSeconds, eventId) == 1;
|
||||
}
|
||||
|
||||
public OutboxRow find(String eventId) {
|
||||
List<OutboxRow> rows = jdbc.query(
|
||||
"SELECT event_id,payload_json,attempt_count FROM sys_audit_outbox WHERE event_id=?",
|
||||
(rs, rowNum) -> new OutboxRow(rs.getString(1), rs.getString(2), rs.getInt(3)), eventId);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public boolean sent(String eventId, String owner) {
|
||||
return jdbc.update("""
|
||||
UPDATE sys_audit_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE event_id=? AND status='SENDING' AND lease_owner=?
|
||||
""", eventId, owner) == 1;
|
||||
}
|
||||
|
||||
public boolean failed(String eventId, String owner, int maxAttempts, int backoffSeconds, String error) {
|
||||
return jdbc.update("""
|
||||
UPDATE sys_audit_outbox
|
||||
SET status=IF(attempt_count>=?,'DEAD','PENDING'),
|
||||
next_retry_at=IF(attempt_count>=?,next_retry_at,
|
||||
DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND)),
|
||||
dead_at=IF(attempt_count>=?,CURRENT_TIMESTAMP(3),dead_at),
|
||||
lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE event_id=? AND status='SENDING' AND lease_owner=?
|
||||
""", maxAttempts, maxAttempts, backoffSeconds, maxAttempts, error, eventId, owner) == 1;
|
||||
}
|
||||
|
||||
public int releaseExpiredClaims() {
|
||||
return jdbc.update("""
|
||||
UPDATE sys_audit_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
|
||||
""");
|
||||
}
|
||||
|
||||
public int compensateDead(int delayMinutes, int maxCompensations) {
|
||||
return jdbc.update("""
|
||||
UPDATE sys_audit_outbox SET status='PENDING',attempt_count=0,
|
||||
compensation_count=compensation_count+1,next_retry_at=CURRENT_TIMESTAMP(3),
|
||||
dead_at=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE status='DEAD' AND compensation_count<?
|
||||
AND dead_at<=DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? MINUTE) LIMIT 50
|
||||
""", maxCompensations, delayMinutes);
|
||||
}
|
||||
|
||||
public int deleteSent(int retentionDays) {
|
||||
return jdbc.update("""
|
||||
DELETE FROM sys_audit_outbox WHERE status='SENT'
|
||||
AND sent_at<DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? DAY) LIMIT 500
|
||||
""", retentionDays);
|
||||
}
|
||||
|
||||
public record OutboxRow(String eventId, String payloadJson, int attemptCount) { }
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 审计 Outbox 的 RocketMQ 发布配置。
|
||||
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
|
||||
*/
|
||||
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
|
||||
@EnableScheduling
|
||||
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
|
||||
@ConditionalOnBean(type = {
|
||||
"com.baiyue.common.audit.AuditOutboxRepository",
|
||||
"org.apache.rocketmq.spring.core.RocketMQTemplate"
|
||||
})
|
||||
@ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "publisher-enabled", havingValue = "true")
|
||||
public class AuditOutboxRocketMqAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
AuditOutboxPublisher auditOutboxPublisher(AuditOutboxRepository repository,
|
||||
RocketMQTemplate rocketMQ,
|
||||
AuditProperties properties,
|
||||
@Value("${spring.application.name:unknown}-${random.uuid}") String owner) {
|
||||
return new AuditOutboxPublisher(repository, rocketMQ, properties, owner);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public class AuditProperties {
|
||||
private String endpoint;
|
||||
private int maxPayloadLength = 16000;
|
||||
private String serviceName = "unknown-service";
|
||||
private final Outbox outbox = new Outbox();
|
||||
private final Rocketmq rocketmq = new Rocketmq();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
@@ -20,4 +22,44 @@ public class AuditProperties {
|
||||
public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; }
|
||||
public String getServiceName() { return serviceName; }
|
||||
public void setServiceName(String serviceName) { this.serviceName = serviceName; }
|
||||
public Outbox getOutbox() { return outbox; }
|
||||
public Rocketmq getRocketmq() { return rocketmq; }
|
||||
|
||||
public static class Outbox {
|
||||
private boolean enabled;
|
||||
private boolean publisherEnabled;
|
||||
private int batchSize = 50;
|
||||
private int leaseSeconds = 60;
|
||||
private int maxAttempts = 5;
|
||||
private int maxCompensations = 2;
|
||||
private int compensationDelayMinutes = 30;
|
||||
private int retentionDays = 30;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public boolean isPublisherEnabled() { return publisherEnabled; }
|
||||
public void setPublisherEnabled(boolean publisherEnabled) { this.publisherEnabled = publisherEnabled; }
|
||||
public int getBatchSize() { return batchSize; }
|
||||
public void setBatchSize(int batchSize) { this.batchSize = batchSize; }
|
||||
public int getLeaseSeconds() { return leaseSeconds; }
|
||||
public void setLeaseSeconds(int leaseSeconds) { this.leaseSeconds = leaseSeconds; }
|
||||
public int getMaxAttempts() { return maxAttempts; }
|
||||
public void setMaxAttempts(int maxAttempts) { this.maxAttempts = maxAttempts; }
|
||||
public int getMaxCompensations() { return maxCompensations; }
|
||||
public void setMaxCompensations(int maxCompensations) { this.maxCompensations = maxCompensations; }
|
||||
public int getCompensationDelayMinutes() { return compensationDelayMinutes; }
|
||||
public void setCompensationDelayMinutes(int compensationDelayMinutes) { this.compensationDelayMinutes = compensationDelayMinutes; }
|
||||
public int getRetentionDays() { return retentionDays; }
|
||||
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
|
||||
}
|
||||
|
||||
public static class Rocketmq {
|
||||
private String topic = "test-baiyue-audit-event-v1";
|
||||
private int sendTimeoutMs = 3000;
|
||||
|
||||
public String getTopic() { return topic; }
|
||||
public void setTopic(String topic) { this.topic = topic; }
|
||||
public int getSendTimeoutMs() { return sendTimeoutMs; }
|
||||
public void setSendTimeoutMs(int sendTimeoutMs) { this.sendTimeoutMs = sendTimeoutMs; }
|
||||
}
|
||||
}
|
||||
|
||||
+29
-2
@@ -3,10 +3,17 @@ 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.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。
|
||||
@@ -15,9 +22,29 @@ import org.springframework.web.client.RestClient;
|
||||
@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
|
||||
@ConditionalOnMissingBean
|
||||
AuditSink auditSink(RestClient.Builder builder, AuditProperties p, ObjectMapper mapper,
|
||||
ObjectProvider<AuditOutboxRepository> outbox,
|
||||
ObjectProvider<PlatformTransactionManager> transactionManagers,
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalServiceToken) {
|
||||
AuditOutboxRepository repository = outbox.getIfAvailable();
|
||||
PlatformTransactionManager transactionManager = transactionManagers.getIfAvailable();
|
||||
if (p.getOutbox().isEnabled() && p.getOutbox().isPublisherEnabled()
|
||||
&& repository != null && transactionManager != null) {
|
||||
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
|
||||
transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
return new OutboxAuditSink(repository, mapper, transactions);
|
||||
}
|
||||
return p.getEndpoint() == null || p.getEndpoint().isBlank()
|
||||
? new LoggingAuditSink()
|
||||
: new HttpAuditSink(builder, p.getEndpoint(), internalServiceToken);
|
||||
}
|
||||
@Bean
|
||||
@ConditionalOnBean(JdbcTemplate.class)
|
||||
@ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "enabled", havingValue = "true")
|
||||
AuditOutboxRepository auditOutboxRepository() { return new AuditOutboxRepository(); }
|
||||
|
||||
@Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); }
|
||||
@Bean BusinessAuditAspect businessAuditAspect() { return new BusinessAuditAspect(); }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。
|
||||
@@ -34,7 +35,7 @@ public class BusinessAuditAspect {
|
||||
private AuditSink sink;
|
||||
@Resource
|
||||
private SensitiveDataSanitizer sanitizer;
|
||||
@Value("${baiyue.audit.service-name:unknown-service}")
|
||||
@Value("${baiyue.audit.service-name:${spring.application.name:unknown-service}}")
|
||||
private String serviceName;
|
||||
private final ExpressionParser parser = new SpelExpressionParser();
|
||||
|
||||
@@ -56,7 +57,7 @@ public class BusinessAuditAspect {
|
||||
HttpServletRequest req = request();
|
||||
AuthenticatedUser user = currentUser();
|
||||
String resourceId = evaluateResourceId(pjp, a.resourceId());
|
||||
sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(),
|
||||
sink.publish(new AuditEvent(UUID.randomUUID().toString(), 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(),
|
||||
|
||||
@@ -8,17 +8,28 @@ import org.springframework.web.client.RestClient;
|
||||
* HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。
|
||||
*/
|
||||
public class HttpAuditSink implements AuditSink {
|
||||
public static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
|
||||
private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT");
|
||||
private final RestClient client;
|
||||
private final String endpoint;
|
||||
private final String internalServiceToken;
|
||||
|
||||
public HttpAuditSink(RestClient.Builder builder, String endpoint) {
|
||||
public HttpAuditSink(RestClient.Builder builder, String endpoint, String internalServiceToken) {
|
||||
this.client = builder.build();
|
||||
this.endpoint = endpoint;
|
||||
this.internalServiceToken = internalServiceToken;
|
||||
}
|
||||
|
||||
@Override public void publish(AuditEvent event) {
|
||||
try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); }
|
||||
@Override
|
||||
public void publish(AuditEvent event) {
|
||||
try {
|
||||
client.post()
|
||||
.uri(endpoint)
|
||||
.header(INTERNAL_SERVICE_TOKEN_HEADER, internalServiceToken)
|
||||
.body(event)
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
catch (RuntimeException ex) {
|
||||
auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}",
|
||||
ex.getClass().getSimpleName(), ex.getMessage(), event);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.transaction.support.TransactionOperations;
|
||||
|
||||
/** 将审计事件先持久化到业务库 Outbox,核心业务不等待 MQ。 */
|
||||
public class OutboxAuditSink implements AuditSink {
|
||||
private static final Logger log = LoggerFactory.getLogger(OutboxAuditSink.class);
|
||||
private final AuditOutboxRepository repository;
|
||||
private final ObjectMapper json;
|
||||
private final TransactionOperations transactions;
|
||||
|
||||
public OutboxAuditSink(AuditOutboxRepository repository, ObjectMapper json,
|
||||
TransactionOperations transactions) {
|
||||
this.repository = repository;
|
||||
this.json = json;
|
||||
this.transactions = transactions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(AuditEvent event) {
|
||||
try {
|
||||
String payload = json.writeValueAsString(event);
|
||||
transactions.executeWithoutResult(status -> repository.append(event, payload));
|
||||
} catch (JsonProcessingException | RuntimeException exception) {
|
||||
log.error("审计事件写入本地 Outbox 失败 eventId={} eventType={}",
|
||||
event.eventId(), event.eventType(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -1 +1,2 @@
|
||||
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
|
||||
com.baiyue.common.audit.AuditOutboxRocketMqAutoConfiguration
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import static org.springframework.test.web.client.ExpectedCount.once;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withAccepted;
|
||||
|
||||
class HttpAuditSinkTest {
|
||||
private static final String ENDPOINT = "http://audit-service/internal/audit/events";
|
||||
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
@Test
|
||||
void sendsInternalServiceToken() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||
server.expect(once(), requestTo(ENDPOINT))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(header(HttpAuditSink.INTERNAL_SERVICE_TOKEN_HEADER, TOKEN))
|
||||
.andRespond(withAccepted().contentType(MediaType.APPLICATION_JSON));
|
||||
|
||||
new HttpAuditSink(builder, ENDPOINT, TOKEN).publish(sampleEvent());
|
||||
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private AuditEvent sampleEvent() {
|
||||
return new AuditEvent("audit-1", "ROLE_UPDATE", "ROLE", "1", "update role",
|
||||
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB",
|
||||
"PUT", "/api/platform/v1/roles/1", "127.0.0.1", 10L, true,
|
||||
null, null, null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.transaction.support.TransactionOperations;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(ReliableAuditDeliveryTest.Config.class)
|
||||
class ReliableAuditDeliveryTest {
|
||||
@MockBean
|
||||
private AuditOutboxRepository repository;
|
||||
@MockBean
|
||||
private RocketMQTemplate rocketMQ;
|
||||
@MockBean
|
||||
private TransactionOperations transactions;
|
||||
@Resource
|
||||
private OutboxAuditSink sink;
|
||||
@Resource
|
||||
private AuditOutboxPublisher publisher;
|
||||
|
||||
@Test
|
||||
void storesEventBeforeDelivery() {
|
||||
AuditEvent event = event("audit-1");
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
java.util.function.Consumer<?> callback = invocation.getArgument(0);
|
||||
((java.util.function.Consumer<Object>) callback).accept(null);
|
||||
return null;
|
||||
}).when(transactions).executeWithoutResult(any());
|
||||
|
||||
sink.publish(event);
|
||||
|
||||
verify(repository).append(eq(event), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksDeliveredMessageSent() {
|
||||
when(repository.publishableIds(50)).thenReturn(List.of("audit-1"));
|
||||
when(repository.claim("audit-1", "test-owner", 60)).thenReturn(true);
|
||||
when(repository.find("audit-1"))
|
||||
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-1", "{}", 1));
|
||||
|
||||
publisher.publish();
|
||||
|
||||
verify(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
|
||||
verify(repository).sent("audit-1", "test-owner");
|
||||
}
|
||||
|
||||
@Test
|
||||
void schedulesRetryAfterMqFailure() {
|
||||
when(repository.publishableIds(50)).thenReturn(List.of("audit-2"));
|
||||
when(repository.claim("audit-2", "test-owner", 60)).thenReturn(true);
|
||||
when(repository.find("audit-2"))
|
||||
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-2", "{}", 1));
|
||||
doThrow(new IllegalStateException("broker unavailable"))
|
||||
.when(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
|
||||
|
||||
publisher.publish();
|
||||
|
||||
verify(repository).failed("audit-2", "test-owner", 5, 5, "broker unavailable");
|
||||
}
|
||||
|
||||
private static AuditEvent event(String id) {
|
||||
return new AuditEvent(id, "ROLE_UPDATE", "ROLE", "1", "修改角色",
|
||||
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB",
|
||||
"PUT", "/roles/1", "127.0.0.1", 10, true, null, null,
|
||||
null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam-service");
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class Config {
|
||||
@Bean
|
||||
ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
|
||||
|
||||
@Bean
|
||||
AuditProperties properties() {
|
||||
AuditProperties value = new AuditProperties();
|
||||
value.getOutbox().setBatchSize(50);
|
||||
value.getOutbox().setLeaseSeconds(60);
|
||||
value.getOutbox().setMaxAttempts(5);
|
||||
value.getRocketmq().setTopic("test-baiyue-audit-event-v1");
|
||||
return value;
|
||||
}
|
||||
|
||||
@Bean
|
||||
OutboxAuditSink sink(AuditOutboxRepository repository, ObjectMapper json,
|
||||
TransactionOperations transactions) {
|
||||
return new OutboxAuditSink(repository, json, transactions);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AuditOutboxPublisher publisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
|
||||
AuditProperties properties) {
|
||||
return new AuditOutboxPublisher(repository, rocketMQ, properties, "test-owner");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user