优化
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.outbox.OutboxOperations;
|
||||
import com.baiyue.common.core.outbox.EventOperationsTokenGuard;
|
||||
import com.baiyue.common.event.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Set;
|
||||
|
||||
/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */
|
||||
@RestController
|
||||
@RequestMapping("/internal/firstleg/event-operations")
|
||||
public class EventOperationsController {
|
||||
private final EventOperationsTokenGuard guard;
|
||||
private final OutboxOperations operations;
|
||||
|
||||
public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions,
|
||||
ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) {
|
||||
guard = new EventOperationsTokenGuard(token);
|
||||
operations = new OutboxOperations(jdbc, transactions,
|
||||
new OutboxOperations.Layout("fl_inbound_outbox", "id", "publish_status", Set.of(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED)),
|
||||
json -> {
|
||||
try {
|
||||
EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid();
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("Hash mismatch");
|
||||
}
|
||||
return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash());
|
||||
} catch (Exception invalid) {
|
||||
throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/{entryId}")
|
||||
public ApiResponse<OutboxOperations.Entry> inspect(
|
||||
@RequestHeader(value = "X-Event-Operations-Token", required = false) String token,
|
||||
@PathVariable String entryId) {
|
||||
guard.require(token);
|
||||
return ApiResponse.success(operations.inspect(entryId));
|
||||
}
|
||||
|
||||
@PostMapping("/{entryId}/retry")
|
||||
public ApiResponse<OutboxOperations.RetryResult> retry(
|
||||
@RequestHeader(value = "X-Event-Operations-Token", required = false) String token,
|
||||
@PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) {
|
||||
guard.require(token);
|
||||
return ApiResponse.success(operations.retry(entryId, request));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -10,7 +10,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
@@ -68,7 +67,7 @@ public class CarrierScheduleNodeOutboxService {
|
||||
long aggregateVersion = Math.max(0, version == null ? 0 : version);
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
|
||||
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
|
||||
fact.getActualAt().atZone(zone).toInstant(),
|
||||
"0", "CONTAINER_BOOKING", string(containerBookingId),
|
||||
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
|
||||
payloadNode, payloadHash).requireValid();
|
||||
|
||||
+22
-15
@@ -29,35 +29,42 @@ public class LoadingCompletedOutboxRepository {
|
||||
.orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId)
|
||||
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
|
||||
}
|
||||
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
public boolean claim(long id, String worker, LocalDateTime now) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id)
|
||||
.in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
|
||||
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
|
||||
.or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
||||
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
|
||||
.setSql("retry_count = retry_count + CASE WHEN publish_status='PROCESSING' THEN 1 ELSE 0 END")
|
||||
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
|
||||
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
|
||||
.eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
||||
.set(InboundOutboxEntity::getLockedBy, worker).set(InboundOutboxEntity::getLockedAt, now)) == 1;
|
||||
}
|
||||
|
||||
public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)
|
||||
.le(InboundOutboxEntity::getLockedAt, cutoff)
|
||||
.set(InboundOutboxEntity::getPublishStatus, next == null ? "DEAD" : "PENDING")
|
||||
.set(InboundOutboxEntity::getRetryCount, failures).set(InboundOutboxEntity::getNextRetryAt, next)
|
||||
.set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null)
|
||||
.set(InboundOutboxEntity::getLastError, truncate(error)));
|
||||
}
|
||||
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
|
||||
public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker)
|
||||
public int published(long id, String worker, LocalDateTime now) { return mapper.update(null, claimedBase(id, worker)
|
||||
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now)
|
||||
.set(InboundOutboxEntity::getNextRetryAt, null)
|
||||
.set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null)
|
||||
.set(InboundOutboxEntity::getLastError, null)); }
|
||||
public void dead(long id, String worker, int failures, String error) {
|
||||
mapper.update(null, claimedBase(id, worker)
|
||||
public int dead(long id, String worker, int failures, String error) {
|
||||
return mapper.update(null, claimedBase(id, worker)
|
||||
.set(InboundOutboxEntity::getPublishStatus, "DEAD")
|
||||
.set(InboundOutboxEntity::getRetryCount, failures)
|
||||
.set(InboundOutboxEntity::getNextRetryAt, null)
|
||||
.set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null)
|
||||
.set(InboundOutboxEntity::getLastError, truncate(error)));
|
||||
}
|
||||
public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker)
|
||||
public int retry(long id, String worker, LocalDateTime retryAt, String error) { return mapper.update(null, claimedBase(id, worker)
|
||||
.setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
|
||||
.set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); }
|
||||
|
||||
+56
-30
@@ -1,6 +1,12 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryProducer;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
|
||||
import com.baiyue.common.web.EventDeliveryMetrics;
|
||||
import com.baiyue.common.web.EventDeliveryMetrics.Stream;
|
||||
import com.baiyue.common.web.EventDeliveryObservation;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.web.EventDeliveryFailure;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -21,10 +27,12 @@ import java.util.UUID;
|
||||
|
||||
/** 至少一次投递国内头程业务事件;Lastleg按 eventId + payloadHash 幂等接收。 */
|
||||
@Component @Slf4j
|
||||
public class LoadingCompletedOutboxRelay {
|
||||
public class LoadingCompletedOutboxRelay implements EventDeliveryProducer {
|
||||
@Resource private LoadingCompletedOutboxRepository repository;
|
||||
@Resource private LastlegEventClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventDeliveryJournal journal;
|
||||
@Resource private EventDeliveryMetrics metrics;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60;
|
||||
|
||||
@@ -37,45 +45,63 @@ public class LoadingCompletedOutboxRelay {
|
||||
public void relay() {
|
||||
if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive");
|
||||
var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter);
|
||||
LocalDateTime now=LocalDateTime.now();
|
||||
LocalDateTime expiredLeaseBefore=now.minusSeconds(leaseSeconds);
|
||||
for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) {
|
||||
String workerId = UUID.randomUUID().toString();
|
||||
now = LocalDateTime.now();
|
||||
expiredLeaseBefore = now.minusSeconds(leaseSeconds);
|
||||
|
||||
if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue;
|
||||
InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId);
|
||||
if(row==null) continue;
|
||||
LocalDateTime scan = LocalDateTime.now();
|
||||
for (InboundOutboxEntity candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) {
|
||||
LocalDateTime started = LocalDateTime.now();
|
||||
if ("PROCESSING".equals(candidate.getPublishStatus())) {
|
||||
int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1;
|
||||
LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures));
|
||||
journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next,
|
||||
() -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds),
|
||||
failures, next, "投递租约超时,消费结果未知"));
|
||||
continue; // Recovery always waits for the new backoff window; never sends in this pass.
|
||||
}
|
||||
String worker = UUID.randomUUID().toString();
|
||||
String type = Stream.FIRSTLEG.type(candidate.getEventType());
|
||||
if (!journal.begin(worker, Stream.FIRSTLEG.service, type, String.valueOf(candidate.getId()),
|
||||
EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FIRSTLEG.target(type), started,
|
||||
() -> repository.claim(candidate.getId(), worker, started))) continue;
|
||||
InboundOutboxEntity row = repository.claimed(candidate.getId(), worker);
|
||||
if (row == null) continue;
|
||||
int failures = row.getRetryCount() == null ? 0 : row.getRetryCount();
|
||||
if (policy.exhausted(failures)) {
|
||||
repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)");
|
||||
journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null,
|
||||
() -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)"));
|
||||
continue;
|
||||
}
|
||||
Outcome outcome;
|
||||
String diagnostic = null;
|
||||
String auditError = null;
|
||||
boolean permanent = false;
|
||||
boolean requestStarted = false;
|
||||
try {
|
||||
EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class);
|
||||
EventEnvelopeValidator.requireValid(event);
|
||||
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
|
||||
requestStarted = true;
|
||||
var response=client.receive(internalToken,event);
|
||||
requireAcknowledgement(event, response);
|
||||
repository.published(row.getId(),workerId,LocalDateTime.now());
|
||||
} catch(Exception exception) {
|
||||
boolean permanent = EventDeliveryFailure.isPermanent(exception)
|
||||
var response = client.receive(internalToken, event);
|
||||
EventDeliveryFailure.requireSuccessful(response);
|
||||
response.data().requireMatches(event);
|
||||
outcome = Outcome.valueOf(response.data().outcome().name());
|
||||
} catch (Exception exception) {
|
||||
permanent = EventDeliveryFailure.isPermanent(exception)
|
||||
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
|
||||
String error = EventDeliveryFailure.diagnostic(exception, internalToken);
|
||||
if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error);
|
||||
else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), error);
|
||||
log.warn("头程业务事件投递失败: eventType={}, eventKey={}, reason={}",
|
||||
row.getEventType(), row.getEventKey(), error);
|
||||
diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken);
|
||||
auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted);
|
||||
outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY;
|
||||
}
|
||||
// Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure.
|
||||
LocalDateTime finished = LocalDateTime.now();
|
||||
LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null;
|
||||
Outcome result = outcome;
|
||||
String error = diagnostic;
|
||||
int retries = failures + 1;
|
||||
journal.complete(worker, result, finished, auditError, next, () -> {
|
||||
if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error);
|
||||
if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, next, error);
|
||||
return repository.published(row.getId(), worker, finished);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAcknowledgement(EventEnvelope event,
|
||||
ApiResponse<com.baiyue.common.event.DeliveryAck> response) {
|
||||
EventDeliveryFailure.requireSuccessful(response);
|
||||
response.data().requireMatches(event);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}")
|
||||
public void sampleMetrics() { metrics.sample(Stream.FIRSTLEG, LocalDateTime.now()); }
|
||||
}
|
||||
|
||||
+1
-2
@@ -11,7 +11,6 @@ import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2;
|
||||
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV3;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
|
||||
@@ -124,7 +123,7 @@ public class LoadingCompletedOutboxService {
|
||||
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
|
||||
String traceId = EventIdentifiers.newUuid();
|
||||
return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 3,
|
||||
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN",
|
||||
Instant.now(), String.valueOf(accountId), "LOADING_PLAN",
|
||||
String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION,
|
||||
traceId, eventKey, null, payloadNode, hash).requireValid();
|
||||
}
|
||||
|
||||
+2
-4
@@ -8,7 +8,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasContainerUnloadedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
@@ -83,9 +82,8 @@ public class OverseasContainerEventConsumer {
|
||||
if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) {
|
||||
throw new EventProtocolException("不支持的海外货柜事件");
|
||||
}
|
||||
if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN
|
||||
|| !"CONTAINER_BOOKING".equals(event.aggregateType())) {
|
||||
throw new EventProtocolException("海外货柜事件区域或聚合类型无效");
|
||||
if (!"CONTAINER_BOOKING".equals(event.aggregateType())) {
|
||||
throw new EventProtocolException("海外货柜事件聚合类型无效");
|
||||
}
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
|
||||
throw new EventProtocolException("payloadHash 与事件负载不一致");
|
||||
|
||||
-2
@@ -9,7 +9,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasDeliveryCompletedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
@@ -102,7 +101,6 @@ public class OverseasDeliveryCompletedConsumer {
|
||||
private void validate(EventEnvelope event) {
|
||||
event.requireValid();
|
||||
if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1
|
||||
|| event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN
|
||||
|| !"INBOUND_ORDER".equals(event.aggregateType())) {
|
||||
throw new EventProtocolException("不支持的海外派送完成事件");
|
||||
}
|
||||
|
||||
+1
-3
@@ -9,7 +9,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
@@ -87,8 +86,7 @@ public class OverseasInboundOrderDetainedConsumer {
|
||||
private void validateEnvelope(EventEnvelope event) {
|
||||
event.requireValid();
|
||||
if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
|
||||
|| event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS
|
||||
|| event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) {
|
||||
|| event.eventVersion() != 1 || !"INBOUND_ORDER".equals(event.aggregateType())) {
|
||||
throw new EventProtocolException("不支持的海外入仓单暂扣事件");
|
||||
}
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${FIRSTLEG_DB_USERNAME:root}
|
||||
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
||||
|
||||
# 仅本地开发环境输出 MyBatis 执行 SQL、参数与结果摘要到 IDEA 控制台。
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
@@ -1,6 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${FIRSTLEG_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
||||
username: ${FIRSTLEG_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
||||
password: ${FIRSTLEG_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
||||
@@ -2,24 +2,24 @@ server:
|
||||
port: ${FIRSTLEG_PORT:9202}
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
active: ${SPRING_PROFILES_ACTIVE:test}
|
||||
application:
|
||||
name: firstleg-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
server-addr: ${NACOS_SERVER_ADDR}
|
||||
username: ${NACOS_USERNAME}
|
||||
password: ${NACOS_PASSWORD}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
config.namespace: ${NACOS_NAMESPACE:public}
|
||||
namespace: ${NACOS_NAMESPACE}
|
||||
config.namespace: ${NACOS_NAMESPACE}
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
|
||||
@@ -56,10 +56,8 @@ baiyue:
|
||||
max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
|
||||
lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
|
||||
security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
lastleg:
|
||||
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}
|
||||
clients:
|
||||
order:
|
||||
url: ${ORDER_SERVICE_URL:}
|
||||
|
||||
@@ -1079,3 +1079,38 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku (
|
||||
KEY idx_dropship_carton_sku_code (sku_code,carton_id),
|
||||
CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id)
|
||||
) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_delivery_retry_audit (
|
||||
request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键',
|
||||
producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名',
|
||||
entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '原事件类型',
|
||||
payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识',
|
||||
reason VARCHAR(500) NOT NULL COMMENT '人工重试原因',
|
||||
previous_failures INT NOT NULL COMMENT '重试前累计失败次数',
|
||||
previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误',
|
||||
requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间',
|
||||
PRIMARY KEY (request_id),
|
||||
KEY idx_event_retry_entry (producer_table,entry_id,requested_at),
|
||||
KEY idx_event_retry_event (event_id,requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_delivery_attempt (
|
||||
attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键',
|
||||
service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型',
|
||||
entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键',
|
||||
event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空',
|
||||
target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务',
|
||||
started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间',
|
||||
finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间',
|
||||
duration_ms BIGINT NULL COMMENT '尝试耗时毫秒',
|
||||
outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED',
|
||||
error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间',
|
||||
PRIMARY KEY (attempt_id),
|
||||
KEY idx_delivery_attempt_entry (service_name,entry_id,started_at),
|
||||
KEY idx_delivery_attempt_outcome (outcome,started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class EventOperationsControllerTest {
|
||||
@Test void credentialFailureCannotReadOrMutateDatabase() {
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
var transactions = mock(PlatformTransactionManager.class);
|
||||
for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) {
|
||||
var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured);
|
||||
assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class);
|
||||
}
|
||||
verifyNoInteractions(jdbc, transactions);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -30,9 +30,9 @@ class LoadingCompletedOutboxRepositoryTest {
|
||||
when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1);
|
||||
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0);
|
||||
|
||||
assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue();
|
||||
assertThat(repository.claim(1L, "worker", now)).isTrue();
|
||||
|
||||
assertThat(wrapper.getValue().getSqlSegment())
|
||||
.contains("next_retry_at", "<=", "publish_status", "locked_at");
|
||||
.contains("next_retry_at", "<=", "publish_status").doesNotContain("locked_at");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class OutboxLeaseDatabaseTest {
|
||||
@Test void loadingCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE TABLE fl_inbound_outbox(id BIGINT PRIMARY KEY,tenant_id BIGINT,aggregate_type VARCHAR(64),aggregate_id BIGINT,event_version INT,aggregate_sequence INT,payload_hash VARCHAR(100),event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,publish_status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.addMapper(InboundOutboxMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource); factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundOutboxMapper.class);
|
||||
var repository = new LoadingCompletedOutboxRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbc.update("INSERT INTO fl_inbound_outbox(id,event_type,publish_status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "FirstLegLoadingPlanCompleted", now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
jdbc.update("UPDATE fl_inbound_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61));
|
||||
assertThat(repository.claim(1L, "new", now)).isFalse();
|
||||
var next = now.plusSeconds(10);
|
||||
assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next,
|
||||
() -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM fl_inbound_outbox", Integer.class)).isEqualTo(1);
|
||||
assertThat(repository.claim(1L, "early", now)).isFalse();
|
||||
assertThat(journal.begin("new", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", next,
|
||||
() -> repository.claim(1L, "new", next))).isTrue();
|
||||
assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null,
|
||||
() -> repository.dead(1L, "old", 12, "late"))).isFalse();
|
||||
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
|
||||
assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null,
|
||||
() -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PROCESSING");
|
||||
assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED");
|
||||
jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied");
|
||||
assertThat(journal.complete("new", Outcome.APPLIED, next, null, null,
|
||||
() -> repository.published(1L, "new", next))).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PUBLISHED");
|
||||
}
|
||||
}
|
||||
+38
-10
@@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.client.LastlegEventClient;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository;
|
||||
@@ -24,6 +23,11 @@ import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableExcept
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -35,11 +39,21 @@ class LoadingCompletedOutboxRelayTest {
|
||||
private ObjectMapper objectMapper;
|
||||
private LoadingCompletedOutboxRelay relay;
|
||||
|
||||
private EventDeliveryJournal journal;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
relay = new LoadingCompletedOutboxRelay();
|
||||
ReflectionTestUtils.setField(relay, "repository", repository);
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
|
||||
journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
ReflectionTestUtils.setField(relay, "journal", journal);
|
||||
lenient().when(repository.published(anyLong(), anyString(), any())).thenReturn(1);
|
||||
lenient().when(repository.retry(anyLong(), anyString(), any(), anyString())).thenReturn(1);
|
||||
lenient().when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1);
|
||||
ReflectionTestUtils.setField(relay, "client", client);
|
||||
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
|
||||
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
|
||||
@@ -52,7 +66,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
|
||||
com.baiyue.common.event.DeliveryAck.of(event, com.baiyue.common.event.DeliveryAck.Outcome.APPLIED)));
|
||||
@@ -68,7 +82,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
|
||||
.thenThrow(new IllegalStateException("failed internal-token\nremote"));
|
||||
@@ -84,7 +98,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow(
|
||||
new NoFallbackAvailableException("No fallback available.",
|
||||
@@ -102,7 +116,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure(
|
||||
ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456"));
|
||||
@@ -110,7 +124,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
relay.relay();
|
||||
|
||||
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq(
|
||||
"IllegalStateException: Lastleg拒绝头程业务事件: code=S0503, message=bridge inbox unavailable, "
|
||||
"EventDeliveryFailure: Consumer rejected event: code=S0503, message=bridge inbox unavailable, "
|
||||
+ "requestId=request-123, traceId=trace-456"));
|
||||
verify(repository, never()).published(anyLong(), anyString(), any());
|
||||
}
|
||||
@@ -120,7 +134,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
|
||||
new com.baiyue.common.event.DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64),
|
||||
@@ -135,7 +149,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
InboundOutboxEntity row = row(event());
|
||||
row.setRetryCount(11);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable"));
|
||||
relay.relay();
|
||||
@@ -149,7 +163,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
InboundOutboxEntity row = row(event());
|
||||
row.setRetryCount(12);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
relay.relay();
|
||||
verifyNoInteractions(client);
|
||||
@@ -157,10 +171,24 @@ class LoadingCompletedOutboxRelayTest {
|
||||
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredLeaseIsAuditedAndBackedOffWithoutImmediateHttp() throws Exception {
|
||||
var row = row(event());
|
||||
row.setPublishStatus("PROCESSING"); row.setLockedBy("old"); row.setRetryCount(1);
|
||||
journal.begin("old", "firstleg", row.getEventType(), "9001", null, "consumer", LocalDateTime.now().minusMinutes(2), () -> true);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.expire(eq(9001L), eq("old"), any(), eq(2), any(), anyString())).thenReturn(1);
|
||||
relay.relay();
|
||||
verify(repository).expire(eq(9001L), eq("old"), any(), eq(2), argThat(next -> next.isAfter(LocalDateTime.now())), anyString());
|
||||
verify(repository, never()).claim(anyLong(), anyString(), any());
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
private InboundOutboxEntity row(EventEnvelope event) throws Exception {
|
||||
InboundOutboxEntity row = new InboundOutboxEntity();
|
||||
row.setId(9001L);
|
||||
row.setEventKey(event.eventId());
|
||||
row.setEventType(event.eventType());
|
||||
row.setPayloadJson(objectMapper.writeValueAsString(event));
|
||||
return row;
|
||||
}
|
||||
@@ -170,7 +198,7 @@ class LoadingCompletedOutboxRelayTest {
|
||||
"LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null,
|
||||
"WH-1", "Warehouse", List.of()));
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1,
|
||||
Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1",
|
||||
Instant.parse("2026-08-20T03:00:01Z"), "1",
|
||||
"LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null,
|
||||
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
+2
-3
@@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasContainerUnloadedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
@@ -83,7 +82,7 @@ class OverseasContainerEventConsumerTest {
|
||||
new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"),
|
||||
Instant.parse("2026-08-20T02:30:00Z"), null));
|
||||
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
|
||||
valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(),
|
||||
valid.occurredAt(), valid.tenantId(), valid.aggregateType(),
|
||||
valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(),
|
||||
valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64));
|
||||
|
||||
@@ -107,7 +106,7 @@ class OverseasContainerEventConsumerTest {
|
||||
var node = objectMapper.valueToTree(payload);
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1,
|
||||
Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1",
|
||||
Instant.parse("2026-08-20T02:31:00Z"), "1",
|
||||
"CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null,
|
||||
"trace-12345678", "correlation-12345678", null, node, hash);
|
||||
}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class OverseasDeliveryCompletedConsumerTest {
|
||||
"101", "IN-1", "201", "FL-201", "201", "ONT8", time);
|
||||
var node = json.valueToTree(payload);
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000001",
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN,
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time,
|
||||
"88", "INBOUND_ORDER", "101", 2L, 1L,
|
||||
"00000000-0000-4000-8000-000000000099", "delivery-101", null, node,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
|
||||
|
||||
+1
-3
@@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
@@ -82,8 +81,7 @@ class OverseasInboundOrderDetainedConsumerTest {
|
||||
"LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z"));
|
||||
var node = objectMapper.valueToTree(payload);
|
||||
return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version),
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(), "88", "INBOUND_ORDER", "1001", version, 1L,
|
||||
"00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user