暂存提交
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
package com.baiyue.eventbridge;
|
||||
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 跨区域事件桥启动入口。
|
||||
*/
|
||||
@MapperScan("com.baiyue.eventbridge.mapper")
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||
public class EventBridgeApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(EventBridgeApplication.class, args);
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.eventbridge.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
@ConfigurationProperties(prefix = "baiyue.event-dispatcher")
|
||||
public record EventDispatcherProperties(
|
||||
URI consumerUrl,
|
||||
int batchSize,
|
||||
int maxAttempts,
|
||||
Duration leaseDuration,
|
||||
Duration initialBackoff,
|
||||
Duration maxBackoff,
|
||||
Duration connectTimeout,
|
||||
Duration requestTimeout) {
|
||||
|
||||
public EventDispatcherProperties {
|
||||
consumerUrl = consumerUrl == null
|
||||
? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl;
|
||||
batchSize = batchSize <= 0 ? 50 : batchSize;
|
||||
maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts;
|
||||
leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30));
|
||||
initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5));
|
||||
maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15));
|
||||
connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3));
|
||||
requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10));
|
||||
}
|
||||
|
||||
private static Duration positiveOrDefault(Duration value, Duration fallback) {
|
||||
return value == null || value.isNegative() || value.isZero() ? fallback : value;
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.eventbridge.dto.BridgeStatus;
|
||||
import com.baiyue.eventbridge.dto.EventReceiveResult;
|
||||
import com.baiyue.eventbridge.dto.EventReplayRequest;
|
||||
import com.baiyue.eventbridge.service.EventInboxService;
|
||||
import com.baiyue.eventbridge.service.EventReplayService;
|
||||
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
/**
|
||||
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
|
||||
@@ -26,13 +29,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RestController
|
||||
@RequestMapping("/internal/event-bridge")
|
||||
public class EventBridgeController {
|
||||
@Resource
|
||||
private EventInboxService eventInboxService;
|
||||
@Resource private EventInboxService eventInboxService;
|
||||
@Resource private EventReplayService eventReplayService;
|
||||
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
|
||||
|
||||
@Resource
|
||||
private InternalServiceTokenGuard internalServiceTokenGuard;
|
||||
|
||||
@Value("${baiyue.region:CN}")
|
||||
@Value("${baiyue.region:OVERSEAS}")
|
||||
private String region;
|
||||
|
||||
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
|
||||
@@ -50,6 +51,18 @@ public class EventBridgeController {
|
||||
return ApiResponse.success(eventInboxService.receive(envelope));
|
||||
}
|
||||
|
||||
@Operation(summary = "人工补投死信事件")
|
||||
@PostMapping("/events/{eventId}/replay")
|
||||
public ApiResponse<Void> replay(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") String operatorId,
|
||||
@PathVariable String eventId,
|
||||
@RequestBody EventReplayRequest request) {
|
||||
internalServiceTokenGuard.verify(token);
|
||||
eventReplayService.replayDead(eventId, operatorId, request.reason());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询事件桥能力状态")
|
||||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
|
||||
@GetMapping("/status")
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.eventbridge.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("bridge_delivery_attempt")
|
||||
public class BridgeDeliveryAttempt {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String eventId;
|
||||
private String sourceRegion;
|
||||
private String targetRegion;
|
||||
private Integer attemptNo;
|
||||
private String status;
|
||||
private String responseCode;
|
||||
private String errorMessage;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public class BridgeInbox {
|
||||
private Integer eventVersion;
|
||||
private String sourceRegion;
|
||||
private String targetRegion;
|
||||
private String tenantId;
|
||||
private String customerId;
|
||||
private String aggregateType;
|
||||
private String aggregateId;
|
||||
private Long aggregateVersion;
|
||||
@@ -32,5 +32,11 @@ public class BridgeInbox {
|
||||
private String payloadHash;
|
||||
private String payloadJson;
|
||||
private String status;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private Integer retryCount;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private String lastError;
|
||||
private LocalDateTime receivedAt;
|
||||
private LocalDateTime processedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.eventbridge.dto;
|
||||
|
||||
public record EventReplayRequest(String reason) {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.eventbridge.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface BridgeDeliveryAttemptMapper extends BaseMapper<BridgeDeliveryAttempt> {
|
||||
}
|
||||
+52
@@ -3,10 +3,62 @@ package com.baiyue.eventbridge.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 入站事件持久化映射。
|
||||
*/
|
||||
@Mapper
|
||||
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
|
||||
@Select("""
|
||||
SELECT event_id FROM bridge_inbox
|
||||
WHERE target_region = 'OVERSEAS'
|
||||
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||
ORDER BY received_at LIMIT #{limit}
|
||||
""")
|
||||
List<String> dispatchCandidates(@Param("limit") int limit);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
|
||||
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
|
||||
WHERE event_id=#{eventId} AND target_region='OVERSEAS'
|
||||
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||
""")
|
||||
int claim(@Param("eventId") String eventId, @Param("owner") String owner,
|
||||
@Param("leaseSeconds") long leaseSeconds);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL
|
||||
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
|
||||
""")
|
||||
int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET retry_count=retry_count+1,
|
||||
status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END,
|
||||
next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL
|
||||
ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END,
|
||||
lease_owner=NULL, lease_until=NULL, last_error=#{error}
|
||||
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
|
||||
""")
|
||||
int markFailed(@Param("eventId") String eventId, @Param("owner") String owner,
|
||||
@Param("maxAttempts") int maxAttempts,
|
||||
@Param("backoffSeconds") long backoffSeconds, @Param("error") String error);
|
||||
|
||||
@Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}")
|
||||
int nextAttemptNo(@Param("eventId") String eventId);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL, lease_until=NULL, last_error=#{reason}
|
||||
WHERE event_id=#{eventId} AND status='DEAD'
|
||||
""")
|
||||
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
|
||||
}
|
||||
|
||||
+5
-2
@@ -13,8 +13,11 @@ import java.security.MessageDigest;
|
||||
*/
|
||||
@Component
|
||||
public class InternalServiceTokenGuard {
|
||||
@Value("${baiyue.security.internal-service-token:}")
|
||||
private String expectedToken;
|
||||
private final String expectedToken;
|
||||
|
||||
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) {
|
||||
this.expectedToken = expectedToken;
|
||||
}
|
||||
|
||||
public void verify(String actualToken) {
|
||||
if (expectedToken == null || expectedToken.isBlank()) {
|
||||
|
||||
+6
-24
@@ -1,7 +1,5 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
@@ -13,21 +11,20 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 入站事件校验与幂等落库服务。
|
||||
*/
|
||||
@Service
|
||||
public class EventInboxService {
|
||||
@Resource
|
||||
private InboxStore inboxStore;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@Resource private InboxStore inboxStore;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Value("${baiyue.region:CN}")
|
||||
private String localRegion;
|
||||
private String localRegion;
|
||||
|
||||
@Transactional
|
||||
public EventReceiveResult receive(EventEnvelope envelope) {
|
||||
@@ -61,24 +58,9 @@ public class EventInboxService {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
|
||||
}
|
||||
if (!hasSameEnvelopeIdentity(existing, envelope)) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件");
|
||||
}
|
||||
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
|
||||
}
|
||||
|
||||
private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) {
|
||||
return Objects.equals(existing.getTenantId(), envelope.tenantId())
|
||||
&& Objects.equals(existing.getEventType(), envelope.eventType())
|
||||
&& Objects.equals(existing.getEventVersion(), envelope.eventVersion())
|
||||
&& Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name())
|
||||
&& Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name())
|
||||
&& Objects.equals(existing.getAggregateType(), envelope.aggregateType())
|
||||
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
||||
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
||||
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion());
|
||||
}
|
||||
|
||||
private BridgeInbox toInbox(EventEnvelope envelope) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
@@ -87,7 +69,7 @@ public class EventInboxService {
|
||||
inbox.setEventVersion(envelope.eventVersion());
|
||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
||||
inbox.setTenantId(envelope.tenantId());
|
||||
inbox.setCustomerId(envelope.tenantId());
|
||||
inbox.setAggregateType(envelope.aggregateType());
|
||||
inbox.setAggregateId(envelope.aggregateId());
|
||||
inbox.setAggregateVersion(envelope.aggregateVersion());
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EventReplayService {
|
||||
@Resource private BridgeInboxMapper inboxMapper;
|
||||
|
||||
public void replayDead(String eventId, String operatorId, String reason) {
|
||||
if (operatorId == null || operatorId.isBlank()) {
|
||||
throw new IllegalArgumentException("人工补投必须提供操作人");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("人工补投必须提供原因");
|
||||
}
|
||||
String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000);
|
||||
if (inboxMapper.replayDead(eventId, auditReason) != 1) {
|
||||
throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
|
||||
}
|
||||
}
|
||||
|
||||
private static String abbreviate(String value, int maxLength) {
|
||||
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
@Component
|
||||
public class HttpLastlegEventClient implements LastlegEventClient {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token:}")
|
||||
private String serviceToken;
|
||||
|
||||
private HttpClient httpClient;
|
||||
|
||||
@PostConstruct
|
||||
void initializeHttpClient() {
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deliver(BridgeInbox row) throws Exception {
|
||||
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
|
||||
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
|
||||
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
|
||||
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
|
||||
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
|
||||
String body = objectMapper.writeValueAsString(envelope);
|
||||
HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl())
|
||||
.timeout(properties.requestTimeout())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Internal-Service-Token", serviceToken)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode responseBody = objectMapper.readTree(response.body());
|
||||
if (response.statusCode() / 100 != 2 || responseBody == null
|
||||
|| !"200".equals(responseBody.path("code").asText())) {
|
||||
throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode());
|
||||
}
|
||||
return String.valueOf(response.statusCode());
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
|
||||
public interface LastlegEventClient {
|
||||
String deliver(BridgeInbox event) throws Exception;
|
||||
}
|
||||
+2
-3
@@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 基于 MyBatis-Plus 的入站幂等存储。
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class MybatisInboxStore implements InboxStore {
|
||||
private final BridgeInboxMapper mapper;
|
||||
@Resource private BridgeInboxMapper mapper;
|
||||
|
||||
@Override
|
||||
public BridgeInbox findByEventId(String eventId) {
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class OverseasEventDispatcher {
|
||||
@Resource private BridgeInboxMapper inboxMapper;
|
||||
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
|
||||
@Resource private LastlegEventClient eventClient;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3s}")
|
||||
public void dispatch() {
|
||||
for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) {
|
||||
dispatchOne(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
void dispatchOne(String eventId) {
|
||||
if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
BridgeInbox event = inboxMapper.selectById(eventId);
|
||||
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
|
||||
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
|
||||
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
|
||||
try {
|
||||
String responseCode = eventClient.deliver(event);
|
||||
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
|
||||
throw new IllegalStateException("事件租约已失效,无法确认投递结果");
|
||||
}
|
||||
finishAttempt(attempt, "DELIVERED", responseCode, null);
|
||||
} catch (Exception exception) {
|
||||
String error = abbreviate(exception.getMessage(), 1000);
|
||||
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
|
||||
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error);
|
||||
log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
|
||||
eventId, retryAttempt, deliveryAttemptNo, error);
|
||||
}
|
||||
}
|
||||
|
||||
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
|
||||
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
|
||||
attempt.setEventId(event.getEventId());
|
||||
attempt.setSourceRegion(event.getSourceRegion());
|
||||
attempt.setTargetRegion(event.getTargetRegion());
|
||||
attempt.setAttemptNo(attemptNo);
|
||||
attempt.setStatus("PROCESSING");
|
||||
attempt.setStartedAt(LocalDateTime.now());
|
||||
attemptMapper.insert(attempt);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) {
|
||||
attempt.setStatus(status);
|
||||
attempt.setResponseCode(responseCode);
|
||||
attempt.setErrorMessage(error);
|
||||
attempt.setFinishedAt(LocalDateTime.now());
|
||||
attemptMapper.updateById(attempt);
|
||||
}
|
||||
|
||||
private long backoffSeconds(int attemptNo) {
|
||||
long initial = properties.initialBackoff().toSeconds();
|
||||
int shift = Math.min(Math.max(attemptNo - 1, 0), 20);
|
||||
long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift;
|
||||
return Math.min(delay, properties.maxBackoff().toSeconds());
|
||||
}
|
||||
|
||||
private static String abbreviate(String value, int maxLength) {
|
||||
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
|
||||
return safe.length() <= maxLength ? safe : safe.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
|
||||
username: ${EVENT_BRIDGE_DB_USERNAME:root}
|
||||
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
|
||||
@@ -1,6 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${EVENT_BRIDGE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
||||
username: ${EVENT_BRIDGE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
||||
password: ${EVENT_BRIDGE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
||||
@@ -1,18 +1,25 @@
|
||||
server:
|
||||
port: ${EVENT_BRIDGE_PORT:9300}
|
||||
port: ${EVENT_BRIDGE_PORT:9312}
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6380}
|
||||
application:
|
||||
name: event-bridge-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
|
||||
username: ${EVENT_BRIDGE_DB_USERNAME:root}
|
||||
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
@@ -22,7 +29,17 @@ spring:
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
|
||||
baiyue:
|
||||
region: ${REGION_CODE:CN}
|
||||
region: ${REGION_CODE:OVERSEAS}
|
||||
event-dispatcher:
|
||||
consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}
|
||||
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s}
|
||||
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
|
||||
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
|
||||
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
|
||||
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
|
||||
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
|
||||
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
|
||||
security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
|
||||
@@ -35,14 +52,3 @@ management:
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
|
||||
logback:
|
||||
rollingpolicy:
|
||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
||||
|
||||
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
|
||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
|
||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
|
||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
|
||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||
|
||||
+6
-20
@@ -1,43 +1,29 @@
|
||||
package com.baiyue.eventbridge.security;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("内部服务令牌校验")
|
||||
@SpringJUnitConfig(InternalServiceTokenGuard.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token")
|
||||
class InternalServiceTokenGuardTest {
|
||||
@Resource private InternalServiceTokenGuard guard;
|
||||
|
||||
@AfterEach
|
||||
void resetToken() {
|
||||
ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token");
|
||||
}
|
||||
@Test
|
||||
void validTokenAllowsAccess() {
|
||||
void 正确令牌允许访问() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
|
||||
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unconfiguredTokenDisablesReceiveEndpoint() {
|
||||
ReflectionTestUtils.setField(guard, "expectedToken", "");
|
||||
void 未配置令牌时默认关闭接收接口() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
|
||||
assertThatThrownBy(() -> guard.verify("anything"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("接口已关闭");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidTokenIsRejected() {
|
||||
ReflectionTestUtils.setField(guard, "expectedToken", "expected");
|
||||
void 错误令牌被拒绝() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
|
||||
assertThatThrownBy(() -> guard.verify("wrong"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("令牌无效");
|
||||
|
||||
+37
-91
@@ -6,16 +6,8 @@ import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.dao.DuplicateKeyException;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -25,35 +17,17 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("跨区域事件收件箱")
|
||||
@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class})
|
||||
@TestPropertySource(properties = "baiyue.region=OVERSEAS")
|
||||
class EventInboxServiceTest {
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@MockBean
|
||||
private InboxStore store;
|
||||
@Resource
|
||||
private EventInboxService service;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class TestConfig {
|
||||
@Bean
|
||||
ObjectMapper objectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void resetRegion() {
|
||||
ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS");
|
||||
}
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final InboxStore store = mock(InboxStore.class);
|
||||
private final EventInboxService service = service("OVERSEAS");
|
||||
|
||||
@Test
|
||||
void firstEventPersistsAndReturnsReceived() {
|
||||
void 首次事件落库并返回已接收() {
|
||||
EventEnvelope envelope = envelope("sha256:placeholder");
|
||||
envelope = withHash(envelope);
|
||||
|
||||
@@ -62,19 +36,31 @@ class EventInboxServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalEventAndDigestDoesNotPersistAgain() {
|
||||
void 数字规范化上线前的历史摘要仍可接收() {
|
||||
EventEnvelope source = envelope("sha256:placeholder");
|
||||
source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150);
|
||||
String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload()));
|
||||
EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
|
||||
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
|
||||
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
|
||||
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同事件和摘要重复接收不再次写入() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||
BridgeInbox existing = existing(envelope.payloadHash());
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventWithDifferentDigestRejectsOverwrite() {
|
||||
void 相同事件但摘要不同时拒绝覆盖() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId()))
|
||||
.thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64)));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
@@ -82,45 +68,20 @@ class EventInboxServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() {
|
||||
EventEnvelope original = withHash(envelope("sha256:placeholder"));
|
||||
EventEnvelope otherTenant = withTenant(original, "20002");
|
||||
when(store.findByEventId(otherTenant.eventId()))
|
||||
.thenReturn(existing(original, original.payloadHash()));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(otherTenant))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("信封身份不一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() {
|
||||
EventEnvelope original = withHash(envelope("sha256:placeholder"));
|
||||
EventEnvelope otherEnvelope = withAggregateVersion(original, 2L);
|
||||
when(store.findByEventId(otherEnvelope.eventId()))
|
||||
.thenReturn(existing(original, original.payloadHash()));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(otherEnvelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("信封身份不一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateInsertAfterRaceIsTreatedAsDuplicate() {
|
||||
void 并发插入冲突后按既有记录判定为重复() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId()))
|
||||
.thenReturn(null, existing(envelope, envelope.payloadHash()));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash()));
|
||||
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetRegionDifferentFromCurrentDeploymentIsRejected() {
|
||||
ReflectionTestUtils.setField(service, "localRegion", "CN");
|
||||
void 目标区域与当前部署区域不一致时拒绝接收() {
|
||||
EventInboxService cnService = service("CN");
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
assertThatThrownBy(() -> cnService.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("目标区域");
|
||||
}
|
||||
@@ -132,6 +93,14 @@ class EventInboxServiceTest {
|
||||
objectMapper.createObjectNode().put("orderId", "90001"), hash);
|
||||
}
|
||||
|
||||
private EventInboxService service(String region) {
|
||||
EventInboxService service = new EventInboxService();
|
||||
ReflectionTestUtils.setField(service, "inboxStore", store);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
ReflectionTestUtils.setField(service, "localRegion", region);
|
||||
return service;
|
||||
}
|
||||
|
||||
private EventEnvelope withHash(EventEnvelope source) {
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
|
||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
||||
@@ -140,32 +109,9 @@ class EventInboxServiceTest {
|
||||
source.correlationId(), source.causationId(), source.payload(), hash);
|
||||
}
|
||||
|
||||
private EventEnvelope withTenant(EventEnvelope source, String tenantId) {
|
||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
||||
source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(),
|
||||
source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(),
|
||||
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
|
||||
}
|
||||
|
||||
private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) {
|
||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
||||
source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(),
|
||||
source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(),
|
||||
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
|
||||
}
|
||||
|
||||
private BridgeInbox existing(EventEnvelope envelope, String hash) {
|
||||
private BridgeInbox existing(String hash) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
inbox.setTenantId(envelope.tenantId());
|
||||
inbox.setEventType(envelope.eventType());
|
||||
inbox.setEventVersion(envelope.eventVersion());
|
||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
||||
inbox.setAggregateType(envelope.aggregateType());
|
||||
inbox.setAggregateId(envelope.aggregateId());
|
||||
inbox.setAggregateVersion(envelope.aggregateVersion());
|
||||
inbox.setHandoverVersion(envelope.handoverVersion());
|
||||
inbox.setEventId("550e8400-e29b-41d4-a716-446655440000");
|
||||
inbox.setPayloadHash(hash);
|
||||
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
|
||||
return inbox;
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class EventReplayServiceTest {
|
||||
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
|
||||
private final EventReplayService service = service();
|
||||
|
||||
@Test
|
||||
void requeuesDeadEventWithAuditReason() {
|
||||
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
|
||||
|
||||
service.replayDead("event-1", "operator-7", "incident-42 approved");
|
||||
|
||||
verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEventThatIsNotDead() {
|
||||
when(mapper.replayDead(anyString(), anyString())).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
private EventReplayService service() {
|
||||
EventReplayService service = new EventReplayService();
|
||||
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OverseasEventDispatcherTest {
|
||||
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
|
||||
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
|
||||
private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
|
||||
private OverseasEventDispatcher dispatcher;
|
||||
private BridgeInbox event;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
EventDispatcherProperties properties = new EventDispatcherProperties(
|
||||
URI.create("https://lastleg.internal/events"), 50, 3,
|
||||
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
|
||||
Duration.ofSeconds(3), Duration.ofSeconds(10));
|
||||
dispatcher = new OverseasEventDispatcher();
|
||||
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
|
||||
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
|
||||
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
|
||||
ReflectionTestUtils.setField(dispatcher, "properties", properties);
|
||||
event = new BridgeInbox();
|
||||
event.setEventId("event-1");
|
||||
event.setSourceRegion("CN");
|
||||
event.setTargetRegion("OVERSEAS");
|
||||
event.setRetryCount(0);
|
||||
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
|
||||
when(inboxMapper.selectById("event-1")).thenReturn(event);
|
||||
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulDeliveryIsConfirmedAndAudited() throws Exception {
|
||||
when(eventClient.deliver(event)).thenReturn("200");
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(inboxMapper).markProcessed(eq("event-1"), anyString());
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDeliveryIsScheduledForRetry() throws Exception {
|
||||
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"));
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalFailureIsMovedToDeadState() throws Exception {
|
||||
event.setRetryCount(2);
|
||||
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventClaimedByAnotherInstanceIsSkipped() {
|
||||
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verifyNoInteractions(eventClient);
|
||||
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
|
||||
}
|
||||
|
||||
private BridgeDeliveryAttempt updatedAttempt() {
|
||||
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||
verify(attemptMapper).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user