日志记录提交

This commit is contained in:
2026-08-05 14:20:04 +08:00
parent 264458920b
commit 06f7f9d2f2
83 changed files with 3256 additions and 27 deletions
+11
View File
@@ -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) { }
}
@@ -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; }
}
}
@@ -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,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");
}
}
}