优化
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.lastleg.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/lastleg/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("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)),
|
||||
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));
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -7,7 +7,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.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
@@ -43,7 +42,7 @@ public class DeliveryCompletedOutboxService {
|
||||
// acknowledgement event. Customer/local overseas orders can still carry
|
||||
// a local sourceInboundId, but deliberately have no domestic sourceOrderId.
|
||||
if (StringUtils.hasText(order.getSourceRegion())
|
||||
&& !RegionCode.CN.name().equalsIgnoreCase(order.getSourceRegion())) continue;
|
||||
&& !"FIRSTLEG".equals(order.getSourceRegion())) continue;
|
||||
// Relabel and warehouse-transfer deliveries are created and completed wholly
|
||||
// in the overseas last-leg domain. They do not have a domestic source order
|
||||
// to acknowledge, so publishing the cross-region event would make receipt
|
||||
@@ -73,7 +72,7 @@ public class DeliveryCompletedOutboxService {
|
||||
order.getFbaWarehouseCode(), completedAt);
|
||||
JsonNode node = objectMapper.valueToTree(payload);
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, RegionCode.OVERSEAS, RegionCode.CN,
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt,
|
||||
order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), aggregateVersion, 1L,
|
||||
EventIdentifiers.newUuid(), eventKey,
|
||||
EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null,
|
||||
|
||||
+22
-14
@@ -24,33 +24,41 @@ public class OverseasFeeOutboxRepository {
|
||||
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
|
||||
.orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit));
|
||||
}
|
||||
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) {
|
||||
public boolean claim(long id, String worker, LocalDateTime now) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
|
||||
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.or(processing ->
|
||||
processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
|
||||
.setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END")
|
||||
.set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker)
|
||||
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
|
||||
.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.set(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1;
|
||||
}
|
||||
|
||||
public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
|
||||
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)
|
||||
.le(LastlegEventOutbox::getLockedAt, cutoff)
|
||||
.set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING")
|
||||
.set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next)
|
||||
.set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null)
|
||||
.set(LastlegEventOutbox::getLastError, truncate(error)));
|
||||
}
|
||||
public LastlegEventOutbox claimed(long id, String worker) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id)
|
||||
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker));
|
||||
}
|
||||
public void published(long id, String worker, LocalDateTime now) {
|
||||
mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
|
||||
public int published(long id, String worker, LocalDateTime now) {
|
||||
return mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null));
|
||||
}
|
||||
public void dead(long id, String worker, int failures, String error) {
|
||||
mapper.update(null, claimed(id, worker, "DEAD")
|
||||
public int dead(long id, String worker, int failures, String error) {
|
||||
return mapper.update(null, claimed(id, worker, "DEAD")
|
||||
.set(LastlegEventOutbox::getRetryCount, failures)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null)
|
||||
.set(LastlegEventOutbox::getLastError, truncate(error)));
|
||||
}
|
||||
public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) {
|
||||
mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount)
|
||||
public int retry(long id, String worker, int retryCount, LocalDateTime next, String error) {
|
||||
return mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error)));
|
||||
}
|
||||
private LambdaUpdateWrapper<LastlegEventOutbox> claimed(long id, String worker, String status) {
|
||||
|
||||
+52
-16
@@ -1,5 +1,11 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
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;
|
||||
@@ -18,10 +24,12 @@ import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class OverseasFeeOutboxRelay {
|
||||
public class OverseasFeeOutboxRelay implements EventDeliveryProducer {
|
||||
@Resource private OverseasFeeOutboxRepository repository;
|
||||
@Resource private DirectEventClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventDeliveryJournal journal;
|
||||
@Resource private EventDeliveryMetrics metrics;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String token;
|
||||
|
||||
@Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12;
|
||||
@@ -35,35 +43,63 @@ public class OverseasFeeOutboxRelay {
|
||||
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(ZoneOffset.UTC);
|
||||
for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(leaseSeconds))) {
|
||||
LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC);
|
||||
for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) {
|
||||
LocalDateTime started = LocalDateTime.now(ZoneOffset.UTC);
|
||||
if ("PROCESSING".equals(candidate.getStatus())) {
|
||||
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();
|
||||
now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
|
||||
if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(leaseSeconds))) continue;
|
||||
String type = Stream.FEE.type(candidate.getEventType());
|
||||
if (!journal.begin(worker, Stream.FEE.service, type, String.valueOf(candidate.getId()),
|
||||
EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FEE.target(type), started,
|
||||
() -> repository.claim(candidate.getId(), worker, started))) continue;
|
||||
LastlegEventOutbox 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(), worker, failures, "自动投递次数已耗尽(含租约超时)");
|
||||
journal.complete(worker, Outcome.DEAD, LocalDateTime.now(ZoneOffset.UTC), "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 envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
|
||||
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
|
||||
requestStarted = true;
|
||||
var response = client.receive(token, envelope);
|
||||
var response = client.receive(token, event);
|
||||
EventDeliveryFailure.requireSuccessful(response);
|
||||
response.data().requireMatches(envelope);
|
||||
repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC));
|
||||
response.data().requireMatches(event);
|
||||
outcome = Outcome.valueOf(response.data().outcome().name());
|
||||
} catch (Exception exception) {
|
||||
String error = EventDeliveryFailure.diagnostic(exception, token);
|
||||
boolean permanent = EventDeliveryFailure.isPermanent(exception)
|
||||
permanent = EventDeliveryFailure.isPermanent(exception)
|
||||
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
|
||||
int retries = failures + 1;
|
||||
if (permanent || policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, error);
|
||||
else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), error);
|
||||
diagnostic = EventDeliveryFailure.diagnostic(exception, token);
|
||||
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(ZoneOffset.UTC);
|
||||
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, retries, next, error);
|
||||
return repository.published(row.getId(), worker, finished);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}")
|
||||
public void sampleMetrics() { metrics.sample(Stream.FEE, LocalDateTime.now(ZoneOffset.UTC)); }
|
||||
}
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@ import com.baiyue.common.event.EventIdentifiers;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
@@ -37,7 +36,7 @@ public class OverseasFeeOutboxService {
|
||||
+ source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action;
|
||||
if (mapper.findByEventKey(eventKey) != null) return;
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN,
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(),
|
||||
source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()),
|
||||
source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
|
||||
|
||||
+53
-26
@@ -1,5 +1,11 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
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;
|
||||
@@ -22,10 +28,12 @@ import java.util.UUID;
|
||||
/** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class InboundUnloadCompletedOutboxRelay {
|
||||
public class InboundUnloadCompletedOutboxRelay implements EventDeliveryProducer {
|
||||
@Resource private UnloadCompletedOutboxRepository repository;
|
||||
@Resource private DirectEventClient 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;
|
||||
|
||||
@@ -38,44 +46,63 @@ public class InboundUnloadCompletedOutboxRelay {
|
||||
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 (LastlegEventOutbox 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;
|
||||
LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId);
|
||||
LocalDateTime scan = LocalDateTime.now();
|
||||
for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) {
|
||||
LocalDateTime started = LocalDateTime.now();
|
||||
if ("PROCESSING".equals(candidate.getStatus())) {
|
||||
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.LASTLEG.type(candidate.getEventType());
|
||||
if (!journal.begin(worker, Stream.LASTLEG.service, type, String.valueOf(candidate.getId()),
|
||||
EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.LASTLEG.target(type), started,
|
||||
() -> repository.claim(candidate.getId(), worker, started))) continue;
|
||||
LastlegEventOutbox 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());
|
||||
EventDeliveryFailure.requireSuccessful(response);
|
||||
response.data().requireMatches(event);
|
||||
outcome = Outcome.valueOf(response.data().outcome().name());
|
||||
} catch (Exception exception) {
|
||||
String error = EventDeliveryFailure.diagnostic(exception, internalToken);
|
||||
boolean permanent = EventDeliveryFailure.isPermanent(exception)
|
||||
permanent = EventDeliveryFailure.isPermanent(exception)
|
||||
|| (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException));
|
||||
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("海外到国内事件投递失败: eventKey={}, reason={}", 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,
|
||||
com.baiyue.common.core.api.ApiResponse<DeliveryAck> response) {
|
||||
EventDeliveryFailure.requireSuccessful(response);
|
||||
response.data().requireMatches(event);
|
||||
}
|
||||
@Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}")
|
||||
public void sampleMetrics() { metrics.sample(Stream.LASTLEG, LocalDateTime.now()); }
|
||||
}
|
||||
|
||||
+1
-2
@@ -7,7 +7,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.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
@@ -78,7 +77,7 @@ public class InboundUnloadCompletedOutboxService {
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
String traceId = EventIdentifiers.newUuid();
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING",
|
||||
1, Instant.now(), "0", "CONTAINER_BOOKING",
|
||||
sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
|
||||
|
||||
|
||||
+21
-17
@@ -32,21 +32,25 @@ public class UnloadCompletedOutboxRepository {
|
||||
.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<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id)
|
||||
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
|
||||
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
|
||||
.or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
|
||||
.setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END")
|
||||
.eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
|
||||
.eq(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
|
||||
.set(LastlegEventOutbox::getStatus, "PROCESSING")
|
||||
.set(LastlegEventOutbox::getLockedBy, worker)
|
||||
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
|
||||
.set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1;
|
||||
}
|
||||
|
||||
public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
|
||||
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)
|
||||
.le(LastlegEventOutbox::getLockedAt, cutoff)
|
||||
.set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING")
|
||||
.set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next)
|
||||
.set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null)
|
||||
.set(LastlegEventOutbox::getLastError, truncate(error)));
|
||||
}
|
||||
public LastlegEventOutbox claimed(long id, String worker) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>()
|
||||
.eq(LastlegEventOutbox::getId, id)
|
||||
@@ -55,8 +59,8 @@ public class UnloadCompletedOutboxRepository {
|
||||
.eq(LastlegEventOutbox::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(LastlegEventOutbox::getStatus, "PUBLISHED")
|
||||
.set(LastlegEventOutbox::getPublishedAt, now)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null)
|
||||
@@ -65,16 +69,16 @@ public class UnloadCompletedOutboxRepository {
|
||||
.set(LastlegEventOutbox::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(LastlegEventOutbox::getStatus, "DEAD")
|
||||
.set(LastlegEventOutbox::getRetryCount, failures)
|
||||
.set(LastlegEventOutbox::getNextRetryAt, null)
|
||||
.set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null)
|
||||
.set(LastlegEventOutbox::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(LastlegEventOutbox::getStatus, "PENDING")
|
||||
.set(LastlegEventOutbox::getNextRetryAt, retryAt)
|
||||
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
package com.baiyue.lastleg.security.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse;
|
||||
import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService;
|
||||
import com.baiyue.lastleg.security.service.LastlegSecurityValidationService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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 java.time.Instant;
|
||||
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/internal/lastleg/security/v1")
|
||||
public class LastlegSecurityController {
|
||||
private final InternalServiceTokenGuard tokenGuard;
|
||||
private final LastlegSecurityProjectionService projections;
|
||||
private final LastlegSecurityValidationService validations;
|
||||
|
||||
public LastlegSecurityController(InternalServiceTokenGuard tokenGuard,
|
||||
LastlegSecurityProjectionService projections,
|
||||
LastlegSecurityValidationService validations) {
|
||||
this.tokenGuard = tokenGuard;
|
||||
this.projections = projections;
|
||||
this.validations = validations;
|
||||
}
|
||||
|
||||
@PostMapping("/events")
|
||||
public ApiResponse<Acknowledgement> receive(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope envelope) {
|
||||
tokenGuard.verify(token);
|
||||
var result = projections.consume(envelope);
|
||||
return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now()));
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
public ApiResponse<ValidationResponse> validate(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody ValidationRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(validations.validate(request));
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package com.baiyue.lastleg.security.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
public final class LastlegSecurityDtos {
|
||||
private LastlegSecurityDtos() { }
|
||||
|
||||
public record Acknowledgement(String eventId, String result, Instant receivedAt) { }
|
||||
|
||||
public record ValidationRequest(@NotNull @Min(1) Long userId,
|
||||
@NotNull @Min(0) Long tenantId,
|
||||
@NotNull @Min(0) Long permissionVersion) { }
|
||||
|
||||
public record ValidationResponse(boolean valid, FailureReason failureReason,
|
||||
Set<String> roles, Set<String> authorities) {
|
||||
public ValidationResponse {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
|
||||
}
|
||||
public static ValidationResponse accepted(Set<String> roles, Set<String> authorities) {
|
||||
return new ValidationResponse(true, null, roles, authorities);
|
||||
}
|
||||
public static ValidationResponse rejected(FailureReason reason) {
|
||||
return new ValidationResponse(false, reason, Set.of(), Set.of());
|
||||
}
|
||||
}
|
||||
|
||||
public enum FailureReason {
|
||||
SECURITY_PROJECTION_MISSING,
|
||||
SECURITY_PROJECTION_NOT_READY,
|
||||
USER_DISABLED,
|
||||
TENANT_DISABLED,
|
||||
PERMISSION_VERSION_STALE
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class LastlegSecurityProjectionRedisMirror {
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}")
|
||||
private String keyPrefix;
|
||||
|
||||
public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) {
|
||||
this.redis = redis;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) {
|
||||
var value = event.projection();
|
||||
try {
|
||||
redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(),
|
||||
objectMapper.writeValueAsString(value));
|
||||
} catch (Exception exception) {
|
||||
log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}",
|
||||
value.aggregateType(), value.aggregateId());
|
||||
}
|
||||
}
|
||||
}
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.IamSecurityEventsV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityProjectionService {
|
||||
private static final List<String> TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED,
|
||||
EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED);
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationEventPublisher events;
|
||||
|
||||
public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
ApplicationEventPublisher events) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConsumeResult consume(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
requireSecurityEnvelope(envelope);
|
||||
String payloadHash = validatedStorageHash(envelope);
|
||||
String payloadJson = envelope.payload().toString();
|
||||
try {
|
||||
jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) "
|
||||
+ "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(),
|
||||
payloadHash, payloadJson);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboxRow existing = jdbc.queryForObject(
|
||||
"SELECT event_type,payload_hash,status FROM ll_security_event_inbox "
|
||||
+ "WHERE event_id=? FOR UPDATE",
|
||||
(rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)),
|
||||
envelope.eventId());
|
||||
if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType())
|
||||
|| existing.payloadHash() == null
|
||||
|| !existing.payloadHash().equalsIgnoreCase(payloadHash)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突");
|
||||
}
|
||||
if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null);
|
||||
}
|
||||
|
||||
ProjectionInput input = input(envelope, payloadHash);
|
||||
ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId());
|
||||
SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide(
|
||||
current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(),
|
||||
input.aggregateVersion(), input.payloadHash());
|
||||
String result = decision.name();
|
||||
if (decision == SecurityProjectionMonotonicity.Decision.APPLY) {
|
||||
upsert(input);
|
||||
result = "APPLIED";
|
||||
events.publishEvent(new ProjectionChanged(input));
|
||||
}
|
||||
jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) "
|
||||
+ "WHERE event_id=?", result, envelope.eventId());
|
||||
return new ConsumeResult(result, input);
|
||||
}
|
||||
|
||||
private void requireSecurityEnvelope(EventEnvelope envelope) {
|
||||
if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION
|
||||
|| envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件");
|
||||
}
|
||||
}
|
||||
|
||||
static String validatedStorageHash(EventEnvelope envelope) {
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
|
||||
envelope.payloadHash())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致");
|
||||
}
|
||||
// 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。
|
||||
return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private ProjectionInput input(EventEnvelope envelope, String payloadHash) {
|
||||
try {
|
||||
return switch (envelope.eventType()) {
|
||||
case EventTypes.IAM_USER_STATUS_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.UserStatusChanged.class);
|
||||
requireIdentity(envelope, "USER", value.authUserId(), value.tenantId());
|
||||
requirePayload(value.status(), value.changedAt(), value.permissionVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
value.status(), value.permissionVersion(), envelope.aggregateVersion(),
|
||||
payloadHash, envelope.eventId(), value.changedAt(), Set.of(), Set.of());
|
||||
}
|
||||
case EventTypes.IAM_TENANT_STATUS_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.TenantStatusChanged.class);
|
||||
requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId());
|
||||
requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(),
|
||||
value.status(), null, envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt(), Set.of(), Set.of());
|
||||
}
|
||||
case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.PermissionVersionChanged.class);
|
||||
requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId());
|
||||
requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireAuthorizationSnapshot(value.roles(), value.authorities());
|
||||
yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt(), value.roles(), value.authorities());
|
||||
}
|
||||
default -> throw new IllegalArgumentException("unsupported type");
|
||||
};
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireIdentity(EventEnvelope envelope, String aggregateType,
|
||||
long aggregateId, long tenantId) {
|
||||
if (!aggregateType.equals(envelope.aggregateType())
|
||||
|| !String.valueOf(aggregateId).equals(envelope.aggregateId())
|
||||
|| !String.valueOf(tenantId).equals(envelope.tenantId())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePayload(String requiredText, Instant changedAt,
|
||||
long payloadVersion, long aggregateVersion) {
|
||||
if (requiredText == null || requiredText.isBlank() || changedAt == null
|
||||
|| payloadVersion != aggregateVersion) {
|
||||
throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireStatus(String status) {
|
||||
if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireAuthorizationSnapshot(Set<String> roles, Set<String> authorities) {
|
||||
if (roles == null || authorities == null
|
||||
|| roles.stream().anyMatch(value -> value == null || value.isBlank())
|
||||
|| authorities.stream().anyMatch(value -> value == null || value.isBlank())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 海外授权快照无效");
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectionRow findForUpdate(String type, String id) {
|
||||
List<ProjectionRow> rows = jdbc.query(
|
||||
"SELECT aggregate_version,payload_hash FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE",
|
||||
(rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private void upsert(ProjectionInput value) {
|
||||
final String rolesJson;
|
||||
final String authoritiesJson;
|
||||
try {
|
||||
rolesJson = objectMapper.writeValueAsString(value.roles());
|
||||
authoritiesJson = objectMapper.writeValueAsString(value.authorities());
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("海外授权快照序列化失败", exception);
|
||||
}
|
||||
jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status,"
|
||||
+ "permission_version,roles_json,authorities_json,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),"
|
||||
+ "security_status=VALUES(security_status),permission_version=VALUES(permission_version),"
|
||||
+ "roles_json=VALUES(roles_json),authorities_json=VALUES(authorities_json),"
|
||||
+ "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash),"
|
||||
+ "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)",
|
||||
value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(),
|
||||
value.permissionVersion(), rolesJson, authoritiesJson, value.aggregateVersion(),
|
||||
value.payloadHash(), value.eventId(), value.changedAt());
|
||||
}
|
||||
|
||||
private record InboxRow(String eventType, String payloadHash, String status) { }
|
||||
private record ProjectionRow(long aggregateVersion, String payloadHash) { }
|
||||
public record ConsumeResult(String result, ProjectionInput projection) { }
|
||||
public record ProjectionChanged(ProjectionInput projection) { }
|
||||
public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status,
|
||||
Long permissionVersion, long aggregateVersion, String payloadHash,
|
||||
String eventId, Instant changedAt, Set<String> roles,
|
||||
Set<String> authorities) { }
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityValidationService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public LastlegSecurityValidationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ValidationResponse validate(ValidationRequest request) {
|
||||
String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId());
|
||||
if (userStatus == null) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
if (!"ACTIVE".equals(userStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.USER_DISABLED);
|
||||
}
|
||||
if (request.tenantId() > 0) {
|
||||
String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId());
|
||||
if (tenantStatus == null) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
if (!"ACTIVE".equals(tenantStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.TENANT_DISABLED);
|
||||
}
|
||||
}
|
||||
List<Long> projectedVersions = jdbc.query(
|
||||
"SELECT permission_version FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?",
|
||||
(rs, row) -> rs.getLong(1),
|
||||
String.valueOf(request.userId()), request.tenantId());
|
||||
if (projectedVersions.isEmpty()) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
long projectedVersion = projectedVersions.get(0);
|
||||
if (request.permissionVersion() < projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE);
|
||||
}
|
||||
if (request.permissionVersion() > projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY);
|
||||
}
|
||||
List<String> snapshots = jdbc.query(
|
||||
"SELECT CONCAT(roles_json, CHAR(10), authorities_json) FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?",
|
||||
(rs, row) -> rs.getString(1), String.valueOf(request.userId()), request.tenantId());
|
||||
if (snapshots.isEmpty() || snapshots.get(0) == null || !snapshots.get(0).contains("\n")) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
try {
|
||||
String[] values = snapshots.get(0).split("\n", 2);
|
||||
return ValidationResponse.accepted(readSet(values[0]), readSet(values[1]));
|
||||
} catch (Exception exception) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
}
|
||||
|
||||
private String status(String type, String id, long tenantId) {
|
||||
List<String> values = jdbc.query("SELECT security_status FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?",
|
||||
(rs, row) -> rs.getString(1), type, id, tenantId);
|
||||
return values.isEmpty() ? null : values.get(0);
|
||||
}
|
||||
|
||||
private Set<String> readSet(String json) throws Exception {
|
||||
if (json == null || json.isBlank()) return Set.of();
|
||||
return objectMapper.readValue(json, new TypeReference<Set<String>>() { });
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
final class SecurityProjectionMonotonicity {
|
||||
private SecurityProjectionMonotonicity() { }
|
||||
|
||||
static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) {
|
||||
if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY;
|
||||
if (incomingVersion < currentVersion) return Decision.STALE;
|
||||
if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION;
|
||||
throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突");
|
||||
}
|
||||
|
||||
enum Decision { APPLY, STALE, DUPLICATE_VERSION }
|
||||
}
|
||||
+1
-3
@@ -7,7 +7,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.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
@@ -41,8 +40,7 @@ public class InboundOrderDetainedOutboxService {
|
||||
String causationId = EventIdentifiers.isSupported(order.getSourceEventId())
|
||||
? order.getSourceEventId() : null;
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt,
|
||||
RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER",
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt, order.getCustomerId(), "INBOUND_ORDER",
|
||||
order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(),
|
||||
eventKey, causationId, payloadNode,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
|
||||
|
||||
+4
-6
@@ -56,8 +56,6 @@ public class LoadingCompletedConsumer {
|
||||
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())
|
||||
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3))
|
||||
throw new EventProtocolException("不支持的尾程交接事件");
|
||||
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
|
||||
throw new EventProtocolException("尾程交接事件区域无效");
|
||||
if (!"LOADING_PLAN".equals(envelope.aggregateType()))
|
||||
throw new EventProtocolException("尾程交接事件聚合类型无效");
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
|
||||
@@ -283,7 +281,7 @@ public class LoadingCompletedConsumer {
|
||||
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource,
|
||||
String containerNo, String sourceContainerBookingId, OwnerSnapshot owner) {
|
||||
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
|
||||
LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId());
|
||||
LastlegOrder existing=orderMapper.findSource(event.tenantId(),"FIRSTLEG",source.sourceInboundId());
|
||||
if(existing!=null) {
|
||||
if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId());
|
||||
return;
|
||||
@@ -302,7 +300,7 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType);
|
||||
order.setDestinationType(destinationType);
|
||||
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
||||
order.setInboundCode(source.inboundCode()); order.setSourceRegion("FIRSTLEG");
|
||||
order.setLxCode("ONE_CLICK_DROPSHIP".equals(orderType) ? source.lxCode() : null);
|
||||
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
||||
@@ -331,7 +329,7 @@ public class LoadingCompletedConsumer {
|
||||
try {
|
||||
orderMapper.insert(order);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(),
|
||||
LastlegOrder raced = orderMapper.findSource(event.tenantId(), "FIRSTLEG",
|
||||
source.sourceInboundId());
|
||||
if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return;
|
||||
throw exception;
|
||||
@@ -399,7 +397,7 @@ public class LoadingCompletedConsumer {
|
||||
String orderHash) {
|
||||
LastlegInboundOrder snapshot = new LastlegInboundOrder();
|
||||
snapshot.setId(order.getId());
|
||||
snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion(event.sourceRegion().name());
|
||||
snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion("FIRSTLEG");
|
||||
snapshot.setSourceInboundId(source.sourceInboundId()); snapshot.setRootInboundId(source.rootInboundId());
|
||||
snapshot.setSourceOrderId(source.sourceOrderId()); snapshot.setSourceOrderNo(source.sourceOrderNo());
|
||||
snapshot.setRootOrderId(source.rootOrderId()); snapshot.setInboundCode(source.inboundCode());
|
||||
|
||||
+1
-3
@@ -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.FirstLegContainerShippingNodeRecordedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.entity.LastlegEventInbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventInboxMapper;
|
||||
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||
@@ -104,8 +103,7 @@ public class ContainerShippingNodeConsumer {
|
||||
throw new EventProtocolException("payloadHash 与船节点事件负载不一致");
|
||||
}
|
||||
if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType())
|
||||
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN
|
||||
|| envelope.targetRegion() != RegionCode.OVERSEAS
|
||||
|| envelope.eventVersion() != 1
|
||||
|| !"CONTAINER_BOOKING".equals(envelope.aggregateType())
|
||||
|| envelope.handoverVersion() == null
|
||||
|| envelope.handoverVersion() != envelope.aggregateVersion() + 1) {
|
||||
|
||||
+2
-4
@@ -6,7 +6,6 @@ import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.common.event.WalletCommandCompletedV1;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -54,9 +53,8 @@ public class WalletCommandCompletedConsumer {
|
||||
try { EventEnvelopeValidator.requireValid(envelope); }
|
||||
catch (RuntimeException exception) { throw bad("资金结果事件信封无效"); }
|
||||
if (!EventTypes.WALLET_COMMAND_COMPLETED.equals(envelope.eventType())
|
||||
|| envelope.eventVersion() != WalletCommandCompletedV1.VERSION
|
||||
|| envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) {
|
||||
throw bad("资金结果事件类型、版本或区域无效");
|
||||
|| envelope.eventVersion() != WalletCommandCompletedV1.VERSION) {
|
||||
throw bad("资金结果事件类型或版本无效");
|
||||
}
|
||||
if (!EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(envelope.payload())).equals(envelope.payloadHash())) {
|
||||
throw bad("资金结果事件载荷哈希无效");
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@ import com.baiyue.common.event.EventIdentifiers;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.common.event.WalletCommandRequestedV1;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
@@ -36,7 +35,7 @@ public class WalletCommandOutboxService {
|
||||
businessNo, amount, remark, idempotencyKey, Instant.now());
|
||||
JsonNode payload = objectMapper.valueToTree(value);
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED,
|
||||
WalletCommandRequestedV1.VERSION, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN,
|
||||
WalletCommandRequestedV1.VERSION, Instant.now(),
|
||||
String.valueOf(customerId), businessType, businessNo, aggregateVersion, null,
|
||||
EventIdentifiers.newUuid(), eventKey, null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
|
||||
|
||||
@@ -8,35 +8,35 @@ spring:
|
||||
max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
mode: never
|
||||
encoding: UTF-8
|
||||
schema-locations: classpath:db/lastleg.sql
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6380}
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
application:
|
||||
name: lastleg-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}
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${LASTLEG_DB_USERNAME:root}
|
||||
password: ${LASTLEG_DB_PASSWORD:123456}
|
||||
url: ${LASTLEG_DB_URL}
|
||||
username: ${LASTLEG_DB_USERNAME}
|
||||
password: ${LASTLEG_DB_PASSWORD}
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
lastleg:
|
||||
@@ -76,21 +76,16 @@ baiyue:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL:}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
|
||||
url: ${FILE_SERVICE_URL:http://file:9206}
|
||||
domestic-file:
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
|
||||
event-bridge:
|
||||
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
|
||||
url: ${FILE_SERVICE_URL:http://file:9206}
|
||||
firstleg:
|
||||
event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
|
||||
outbox-relay:
|
||||
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
|
||||
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
|
||||
security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
jwt-mode: jwks
|
||||
session-mode: redis
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
|
||||
|
||||
@@ -2327,181 +2327,6 @@ CREATE TABLE IF NOT EXISTS ll_customer_visit (
|
||||
KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
|
||||
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
|
||||
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';
|
||||
CREATE TABLE IF NOT EXISTS ll_security_event_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希',
|
||||
payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON',
|
||||
status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED',
|
||||
result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION',
|
||||
received_at DATETIME(3) NOT NULL COMMENT '接收时间',
|
||||
processed_at DATETIME(3) NULL COMMENT '处理完成时间',
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_security_inbox_type_time (event_type,received_at)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_security_projection (
|
||||
aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION',
|
||||
aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID',
|
||||
security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED',
|
||||
permission_version BIGINT NULL COMMENT '当前单调权限版本',
|
||||
roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
|
||||
authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希',
|
||||
last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID',
|
||||
changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间',
|
||||
PRIMARY KEY (aggregate_type,aggregate_id),
|
||||
KEY idx_security_projection_tenant (tenant_id),
|
||||
KEY idx_security_projection_permission (aggregate_id,permission_version)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
|
||||
|
||||
-- 收敛尚未具备授权快照字段的开发/测试库,CREATE TABLE IF NOT EXISTS 不会补充已存在表的字段。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1',
|
||||
'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1',
|
||||
'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL;
|
||||
UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL;
|
||||
ALTER TABLE ll_security_projection
|
||||
MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
|
||||
MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照';
|
||||
|
||||
-- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
|
||||
SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN');
|
||||
SET @platform_admin_overseas_authorities = JSON_ARRAY(
|
||||
'platform:overseas-warehouse:warehouse-transfer:approve',
|
||||
'platform:finance:bill:adjust',
|
||||
'platform:finance:bill:update',
|
||||
'platform:finance:bill:void',
|
||||
'platform:finance:cost:create',
|
||||
'platform:finance:cost:view',
|
||||
'platform:finance:customer-account:adjust',
|
||||
'platform:finance:customer-account:export',
|
||||
'platform:finance:customer-account:list',
|
||||
'platform:finance:customer-arap:export',
|
||||
'platform:finance:customer-arap:list',
|
||||
'platform:finance:customer-statement:export',
|
||||
'platform:finance:customer-statement:list',
|
||||
'platform:finance:customer-terms:update',
|
||||
'platform:finance:customer-terms:view',
|
||||
'platform:finance:fee-source:edit',
|
||||
'platform:finance:fee-source:review',
|
||||
'platform:finance:payment:create',
|
||||
'platform:finance:receipt:create',
|
||||
'platform:finance:receipt:view',
|
||||
'platform:finance:receivable-statistics:export',
|
||||
'platform:finance:receivable-statistics:list',
|
||||
'platform:finance:recharge:review',
|
||||
'platform:finance:reconciliation:adjust',
|
||||
'platform:finance:reconciliation:create',
|
||||
'platform:finance:reconciliation:issue',
|
||||
'platform:finance:reconciliation:review',
|
||||
'platform:finance:reconciliation:void',
|
||||
'platform:finance:settlement-ap:create',
|
||||
'platform:finance:settlement-ap:export',
|
||||
'platform:finance:settlement-ap:list',
|
||||
'platform:finance:settlement-ar:create',
|
||||
'platform:finance:settlement-ar:export',
|
||||
'platform:finance:settlement-ar:list',
|
||||
'platform:finance:settlement:void',
|
||||
'platform:overseas-business:looseboard:create',
|
||||
'platform:overseas-business:looseboard:edit',
|
||||
'platform:overseas-business:looseboard:list',
|
||||
'platform:overseas-business:looseboard:status',
|
||||
'platform:overseas-business:purchase:edit',
|
||||
'platform:overseas-business:purchase:list',
|
||||
'platform:overseas-business:purchase:review',
|
||||
'platform:overseas-business:truck:create',
|
||||
'platform:overseas-business:truck:edit',
|
||||
'platform:overseas-business:truck:list',
|
||||
'platform:overseas-business:truck:status',
|
||||
'platform:overseas-master-data:exchange-rate:create',
|
||||
'platform:overseas-master-data:exchange-rate:edit',
|
||||
'platform:overseas-master-data:exchange-rate:export',
|
||||
'platform:overseas-master-data:exchange-rate:list',
|
||||
'platform:overseas-master-data:exchange-rate:status',
|
||||
'platform:overseas-master-data:quote:create',
|
||||
'platform:overseas-master-data:quote:edit',
|
||||
'platform:overseas-master-data:quote:export',
|
||||
'platform:overseas-master-data:quote:list',
|
||||
'platform:overseas-master-data:quote:status',
|
||||
'platform:overseas-master-data:vehicle:create',
|
||||
'platform:overseas-master-data:vehicle:edit',
|
||||
'platform:overseas-master-data:vehicle:export',
|
||||
'platform:overseas-master-data:vehicle:list',
|
||||
'platform:overseas-master-data:vehicle:status',
|
||||
'platform:overseas-warehouse:customer-visit:edit',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:delivery:documents',
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:dispatch:cancel',
|
||||
'platform:overseas-warehouse:dispatch:edit',
|
||||
'platform:overseas-warehouse:dispatch:list',
|
||||
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:inbound-order:create',
|
||||
'platform:overseas-warehouse:inbound-order:edit',
|
||||
'platform:overseas-warehouse:inbound-order:fba-appointment',
|
||||
'platform:overseas-warehouse:inbound-order:list',
|
||||
'platform:overseas-warehouse:inbound-order:smart-dispatch',
|
||||
'platform:overseas-warehouse:inventory:adjust',
|
||||
'platform:overseas-warehouse:inventory:edit',
|
||||
'platform:overseas-warehouse:inventory:export',
|
||||
'platform:overseas-warehouse:inventory:list',
|
||||
'platform:overseas-warehouse:last-mile-order:edit',
|
||||
'platform:overseas-warehouse:last-mile-order:export',
|
||||
'platform:overseas-warehouse:last-mile-order:list',
|
||||
'platform:overseas-warehouse:location:create',
|
||||
'platform:overseas-warehouse:location:delete',
|
||||
'platform:overseas-warehouse:location:edit',
|
||||
'platform:overseas-warehouse:location:list',
|
||||
'platform:overseas-warehouse:outbound:cancel',
|
||||
'platform:overseas-warehouse:outbound:create',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:pallet:create',
|
||||
'platform:overseas-warehouse:pallet:delete',
|
||||
'platform:overseas-warehouse:pallet:edit',
|
||||
'platform:overseas-warehouse:pallet:list',
|
||||
'platform:overseas-warehouse:relabel-order:create',
|
||||
'platform:overseas-warehouse:relabel-order:list',
|
||||
'platform:overseas-warehouse:relabel-order:status',
|
||||
'platform:overseas-warehouse:return-cartons:list',
|
||||
'platform:overseas-warehouse:return-inventory:list',
|
||||
'platform:overseas-warehouse:warehouse:create',
|
||||
'platform:overseas-warehouse:warehouse:edit',
|
||||
'platform:overseas-warehouse:warehouse:list',
|
||||
'platform:overseas-warehouse:warehouse:status'
|
||||
);
|
||||
INSERT IGNORE INTO ll_security_projection (
|
||||
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version,
|
||||
payload_hash,last_event_id,changed_at,updated_at
|
||||
) VALUES
|
||||
('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
|
||||
'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'),
|
||||
('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0,
|
||||
SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256),
|
||||
'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000');
|
||||
|
||||
UPDATE ll_security_projection
|
||||
SET roles_json=@platform_admin_overseas_roles,
|
||||
authorities_json=@platform_admin_overseas_authorities,
|
||||
payload_hash=SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256),
|
||||
updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0
|
||||
AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0';
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID',
|
||||
command_event_id VARCHAR(64) NOT NULL COMMENT '原海外资金指令事件ID',
|
||||
@@ -2692,3 +2517,38 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE ll_return_order_discrepancy_stmt FROM @ddl; EXECUTE ll_return_order_discrepancy_stmt; DEALLOCATE PREPARE ll_return_order_discrepancy_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='manually_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason'));
|
||||
PREPARE ll_return_order_manual_completed_stmt FROM @ddl; EXECUTE ll_return_order_manual_completed_stmt; DEALLOCATE PREPARE ll_return_order_manual_completed_stmt;
|
||||
|
||||
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='本服务业务事件逐次投递审计';
|
||||
|
||||
@@ -70,8 +70,7 @@ class DirectEventClientTest {
|
||||
|
||||
private EventEnvelope event(String type) {
|
||||
var payload = new ObjectMapper().createObjectNode().put("businessNo", "test");
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "1", "BUSINESS", "1", 1L, null,
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(), "1", "BUSINESS", "1", 1L, null,
|
||||
"trace", "correlation", null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.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);
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -8,6 +8,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
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.*;
|
||||
|
||||
@@ -16,13 +21,18 @@ class OverseasFeeOutboxRetryTest {
|
||||
var repository = mock(OverseasFeeOutboxRepository.class);
|
||||
var client = mock(DirectEventClient.class);
|
||||
var relay = new OverseasFeeOutboxRelay();
|
||||
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))");
|
||||
ReflectionTestUtils.setField(relay, "journal", new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)));
|
||||
when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1);
|
||||
ReflectionTestUtils.setField(relay, "repository", repository);
|
||||
ReflectionTestUtils.setField(relay, "client", client);
|
||||
ReflectionTestUtils.setField(relay, "objectMapper", new ObjectMapper());
|
||||
var row = new LastlegEventOutbox();
|
||||
row.setId(1L); row.setRetryCount(11); row.setPayloadJson("invalid-json");
|
||||
row.setId(1L); row.setEventType("BusinessFeeSourceChanged"); row.setRetryCount(11); row.setPayloadJson("invalid-json");
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(1L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claim(eq(1L), anyString(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(1L), anyString())).thenReturn(row);
|
||||
relay.relay();
|
||||
row.setRetryCount(12);
|
||||
|
||||
+35
-8
@@ -7,7 +7,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.lastleg.client.DirectEventClient;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
|
||||
@@ -23,6 +22,11 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
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 InboundUnloadCompletedOutboxRelayTest {
|
||||
private ObjectMapper objectMapper;
|
||||
private InboundUnloadCompletedOutboxRelay relay;
|
||||
|
||||
private EventDeliveryJournal journal;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
relay = new InboundUnloadCompletedOutboxRelay();
|
||||
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 InboundUnloadCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox 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(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)));
|
||||
@@ -68,7 +82,7 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox 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("consumer unavailable"));
|
||||
@@ -84,7 +98,7 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox 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.BAD_REQUEST,
|
||||
@@ -102,7 +116,7 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
EventEnvelope event = event();
|
||||
LastlegEventOutbox 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 DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), DeliveryAck.Outcome.APPLIED)))
|
||||
@@ -118,7 +132,7 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
LastlegEventOutbox 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();
|
||||
@@ -132,7 +146,7 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
LastlegEventOutbox 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);
|
||||
@@ -140,6 +154,19 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredLeaseIsAuditedAndBackedOffWithoutImmediateHttp() throws Exception {
|
||||
var row = row(event());
|
||||
row.setStatus("PROCESSING"); row.setLockedBy("old"); row.setRetryCount(1);
|
||||
journal.begin("old", "lastleg", 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 LastlegEventOutbox row(EventEnvelope event) throws Exception {
|
||||
LastlegEventOutbox row = new LastlegEventOutbox();
|
||||
row.setId(9001L);
|
||||
@@ -153,7 +180,7 @@ class InboundUnloadCompletedOutboxRelayTest {
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null));
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000009",
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "1", "CONTAINER_BOOKING", "1001", 7L, 1L,
|
||||
"1", "CONTAINER_BOOKING", "1001", 7L, 1L,
|
||||
"trace-12345678", "correlation-12345678", null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
-1
@@ -116,7 +116,6 @@ class InboundUnloadCompletedOutboxServiceTest {
|
||||
Instant.parse("2026-08-24T09:30:00Z"), Instant.parse("2026-08-24T08:00:00Z"), null);
|
||||
EventEnvelope envelope = new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-24T09:30:01Z"),
|
||||
com.baiyue.common.event.RegionCode.OVERSEAS, com.baiyue.common.event.RegionCode.CN,
|
||||
customerId, "CONTAINER_BOOKING", bookingId, 7, 1L,
|
||||
"123e4567-e89b-12d3-a456-426614174001",
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + bookingId, null,
|
||||
|
||||
+75
-37
@@ -2,55 +2,93 @@ package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
|
||||
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.assertThat;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class OutboxLeaseDatabaseTest {
|
||||
@Test void recoveredLeasesCountFailuresAndOldOwnersCannotChangeNewState() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
@Test void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() 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 ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_type VARCHAR(64),event_version INT,event_key VARCHAR(160),payload_json TEXT,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 ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,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(LastlegEventOutboxMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
try (var session = factory.getObject().openSession(true)) {
|
||||
var mapper = session.getMapper(LastlegEventOutboxMapper.class);
|
||||
var unload = new UnloadCompletedOutboxRepository();
|
||||
ReflectionTestUtils.setField(unload, "mapper", mapper);
|
||||
var fee = new OverseasFeeOutboxRepository(mapper);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (long id : new long[]{1, 2}) {
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(?,?,'PENDING',0,?,?)",
|
||||
id, id == 1 ? EventTypes.OVERSEAS_CONTAINER_UNLOADED : EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
|
||||
now.minusSeconds(1), now.minusMinutes(2));
|
||||
boolean first = id == 1 ? unload.claim(id, "old", now, now.minusSeconds(60)) : fee.claim(id, "old", now, now.minusSeconds(60));
|
||||
assertThat(first).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isZero();
|
||||
jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=?", now.minusSeconds(61), id);
|
||||
boolean second = id == 1 ? unload.claim(id, "new", now, now.minusSeconds(60)) : fee.claim(id, "new", now, now.minusSeconds(60));
|
||||
assertThat(second).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isEqualTo(1);
|
||||
if (id == 1) unload.dead(id, "old", 12, "late failure"); else fee.dead(id, "old", 12, "late failure");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("PROCESSING");
|
||||
if (id == 1) unload.dead(id, "new", 12, "exhausted"); else fee.dead(id, "new", 12, "exhausted");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("DEAD");
|
||||
boolean third = id == 1 ? unload.claim(id, "third", now, now.minusSeconds(60)) : fee.claim(id, "third", now, now.minusSeconds(60));
|
||||
assertThat(third).isFalse();
|
||||
}
|
||||
jdbc.update("UPDATE ll_event_outbox SET status='PENDING',next_retry_at=? WHERE id=2", now.plusMinutes(5));
|
||||
assertThat(fee.claim(2L, "early", now, now.minusSeconds(60))).isFalse();
|
||||
}
|
||||
factory.setDataSource(dataSource); factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class);
|
||||
var repository = new UnloadCompletedOutboxRepository();
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
jdbc.update("UPDATE ll_event_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 ll_event_outbox", Integer.class)).isEqualTo(1);
|
||||
assertThat(repository.claim(1L, "early", now)).isFalse();
|
||||
assertThat(journal.begin("new", "lastleg-service", "OverseasContainerUnloaded", "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 status FROM ll_event_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 status FROM ll_event_outbox", String.class)).isEqualTo("PUBLISHED");
|
||||
}
|
||||
@Test void overseasFeeOutboxRepositoryUsesAtomicAuditAndBackoff() 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 ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,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(LastlegEventOutboxMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource); factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class);
|
||||
var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper);
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
jdbc.update("UPDATE ll_event_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 ll_event_outbox", Integer.class)).isEqualTo(1);
|
||||
assertThat(repository.claim(1L, "early", now)).isFalse();
|
||||
assertThat(journal.begin("new", "lastleg-service", "BusinessFeeSourceChanged", "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 status FROM ll_event_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 status FROM ll_event_outbox", String.class)).isEqualTo("PUBLISHED");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -27,8 +27,8 @@ class UnloadCompletedOutboxRepositoryTest {
|
||||
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1);
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
assertThat(repository.claim(1L, "attempt", now, now.minusMinutes(1))).isTrue();
|
||||
assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status", "locked_at");
|
||||
assertThat(repository.claim(1L, "attempt", now)).isTrue();
|
||||
assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status").doesNotContain("locked_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -43,9 +43,9 @@ class UnloadCompletedOutboxRepositoryTest {
|
||||
when(mapper.update(isNull(LastlegEventOutbox.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", "<=", "status", "locked_at");
|
||||
.contains("next_retry_at", "<=", "status").doesNotContain("locked_at");
|
||||
}
|
||||
}
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LastlegSecurityContractTest {
|
||||
@Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception {
|
||||
String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql");
|
||||
String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql");
|
||||
String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql");
|
||||
String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java");
|
||||
String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java");
|
||||
String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java");
|
||||
String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java");
|
||||
assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox",
|
||||
"PRIMARY KEY (event_id)", "payload_hash CHAR(64)",
|
||||
"CREATE TABLE IF NOT EXISTS ll_security_projection",
|
||||
"PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT",
|
||||
"permission_version BIGINT", "roles_json JSON", "authorities_json JSON");
|
||||
assertThat(baseline).contains("table_name='ll_security_projection' AND column_name='roles_json'",
|
||||
"table_name='ll_security_projection' AND column_name='authorities_json'",
|
||||
"UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL",
|
||||
"UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL");
|
||||
assertThat(baseline).contains("SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN')",
|
||||
"SET @platform_admin_overseas_authorities = JSON_ARRAY(",
|
||||
"('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0",
|
||||
"('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0",
|
||||
"baseline-platform-admin-user-v0",
|
||||
"baseline-platform-admin-authorization-v0",
|
||||
"platform:overseas-warehouse:last-mile-order:list",
|
||||
"platform:overseas-warehouse:inbound-order:list",
|
||||
"platform:overseas-warehouse:dispatch:list",
|
||||
"platform:overseas-business:purchase:list",
|
||||
"platform:overseas-master-data:quote:list",
|
||||
"platform:finance:fee-source:review",
|
||||
"AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'")
|
||||
.doesNotContain("('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0",
|
||||
"baseline-merchant-admin");
|
||||
assertThat(migration).contains(
|
||||
"CREATE TABLE ll_security_event_inbox (",
|
||||
"CREATE TABLE ll_security_projection (",
|
||||
"VALUES ('AUTHORIZATION','1000001',0,NULL,0,",
|
||||
"VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0",
|
||||
"PLATFORM_ADMIN",
|
||||
"baseline-platform-admin-user-v0",
|
||||
"baseline-platform-admin-authorization-v0",
|
||||
"platform:overseas-warehouse:last-mile-order:list",
|
||||
"platform:overseas-warehouse:inbound-order:list",
|
||||
"platform:overseas-warehouse:dispatch:list",
|
||||
"platform:overseas-business:purchase:list",
|
||||
"platform:overseas-master-data:quote:list",
|
||||
"platform:finance:fee-source:review")
|
||||
.doesNotContain("SET @platform_admin_overseas_roles", "UPDATE ll_security_projection",
|
||||
"baseline-merchant-admin");
|
||||
assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id",
|
||||
"default_app_code");
|
||||
assertThat(migration).contains("USE baiyue_lastleg;")
|
||||
.doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security",
|
||||
"USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox",
|
||||
"CREATE TABLE IF NOT EXISTS overseas_security_projection");
|
||||
assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY");
|
||||
assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash")
|
||||
.doesNotContain("LoadingCompletedConsumer");
|
||||
assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败");
|
||||
assertThat(validation).contains("SELECT permission_version", "authorities_json", "tenant_id=?",
|
||||
"SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED",
|
||||
"TENANT_DISABLED", "PERMISSION_VERSION_STALE");
|
||||
assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion")
|
||||
.doesNotContain("Long tokenVersion");
|
||||
}
|
||||
|
||||
@Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception {
|
||||
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java");
|
||||
assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED",
|
||||
"FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型")
|
||||
.doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED",
|
||||
"IAM_PERMISSION_VERSION_CHANGED");
|
||||
}
|
||||
|
||||
@Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception {
|
||||
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java");
|
||||
String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java");
|
||||
String application = read("lastleg-service/src/main/resources/application.yml");
|
||||
assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")",
|
||||
"@PostMapping(\"/events\")", "@PostMapping(\"/validate\")",
|
||||
"com.baiyue.lastleg.security.InternalServiceTokenGuard")
|
||||
.doesNotContain("/internal/overseas-security/v1");
|
||||
assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED");
|
||||
assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}")
|
||||
.doesNotContain("phase1-local-internal-token");
|
||||
}
|
||||
|
||||
private static String read(String relative) throws Exception {
|
||||
Path path = Path.of(relative);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
class SecurityProjectionArchitectureTest {
|
||||
private static final String LASTLEG = "com.baiyue.lastleg..";
|
||||
private static final String CONTROLLER = "com.baiyue.lastleg.security.controller..";
|
||||
private static final String DTO = "com.baiyue.lastleg.security.dto..";
|
||||
private static final String SERVICE = "com.baiyue.lastleg.security.service..";
|
||||
|
||||
private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
|
||||
.importPackages("com.baiyue.lastleg");
|
||||
|
||||
@Test
|
||||
void projectionInternalsDoNotDependOnLastlegBusinessSlices() {
|
||||
classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
|
||||
.should().onlyDependOnClassesThat(
|
||||
resideOutsideOfPackage(LASTLEG)
|
||||
.or(resideInAPackage(CONTROLLER))
|
||||
.or(resideInAPackage(DTO))
|
||||
.or(resideInAPackage(SERVICE))
|
||||
.or(resideInAPackage("com.baiyue.lastleg.security")))
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessSlicesDoNotDependOnProjectionInternals() {
|
||||
noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE)
|
||||
.should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dtoAndServicesStayIndependentFromInternalWebAdapter() {
|
||||
noClasses().that().resideInAnyPackage(DTO, SERVICE)
|
||||
.should().dependOnClassesThat().resideInAPackage(CONTROLLER)
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.IamSecurityEventsV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LastlegSecurityProjectionTransportTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
@Test
|
||||
void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() {
|
||||
EventEnvelope envelope = envelope();
|
||||
|
||||
String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope);
|
||||
|
||||
assertThat(stored).hasSize(64).doesNotStartWith("sha256:");
|
||||
assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTamperedPayloadBeforeInboxMutation() {
|
||||
EventEnvelope source = envelope();
|
||||
EventEnvelope tampered = 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(),
|
||||
"sha256:" + "0".repeat(64));
|
||||
|
||||
assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash");
|
||||
}
|
||||
|
||||
private EventEnvelope envelope() {
|
||||
var value = new IamSecurityEventsV1.PermissionVersionChanged(
|
||||
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"),
|
||||
java.util.Set.of("PLATFORM_ADMIN"),
|
||||
java.util.Set.of("platform:overseas-warehouse:last-mile-order:list"));
|
||||
var payload = objectMapper.valueToTree(value);
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000091",
|
||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
|
||||
Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0",
|
||||
"AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678",
|
||||
null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class LastlegSecurityValidationServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc, new ObjectMapper());
|
||||
|
||||
@Test void disabledUserFailsClosed() {
|
||||
doReturn(List.of("DISABLED")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.USER_DISABLED);
|
||||
}
|
||||
|
||||
@Test void disabledCustomerFailsClosed() {
|
||||
doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.TENANT_DISABLED);
|
||||
}
|
||||
|
||||
@Test void missingUserProjectionFailsClosed() {
|
||||
doReturn(List.of()).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
|
||||
@Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() {
|
||||
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L),
|
||||
List.of("[]\n[\"platform:overseas-warehouse:last-mile-order:list\"]")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test void permissionVersionMustExactlyMatchReliableProjection() {
|
||||
doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L),
|
||||
List.of("ACTIVE"), List.of(9L), List.of("[]\n[]")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.PERMISSION_VERSION_STALE);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test void missingPermissionProjectionFailsClosed() {
|
||||
doReturn(List.of("ACTIVE"), List.of()).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class SecurityProjectionMonotonicityTest {
|
||||
@Test void appliesFirstAndNewerVersions() {
|
||||
assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
|
||||
assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
|
||||
}
|
||||
|
||||
@Test void ignoresOlderAndSameHashVersions() {
|
||||
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.STALE);
|
||||
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION);
|
||||
}
|
||||
|
||||
@Test void rejectsSameVersionWithDifferentHash() {
|
||||
assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突");
|
||||
}
|
||||
}
|
||||
+20
-24
@@ -6,18 +6,18 @@ class LoadingCompletedConsumerTest {
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
|
||||
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",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(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
|
||||
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=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(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
|
||||
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
|
||||
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
|
||||
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); }
|
||||
@Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
|
||||
@Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
|
||||
@Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); }
|
||||
@Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=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(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
|
||||
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
|
||||
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
|
||||
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {
|
||||
assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId"));
|
||||
@@ -25,8 +25,8 @@ class LoadingCompletedConsumerTest {
|
||||
String table=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order ("),schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order_item ("));
|
||||
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
|
||||
}
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
|
||||
service.consume(event());
|
||||
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
|
||||
@@ -64,8 +64,7 @@ class LoadingCompletedConsumerTest {
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0))
|
||||
.set("cartonMarks", json.valueToTree(List.of("BOX-6","BOX-7","BOX-8")));
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
ArgumentCaptor<LastlegOrder> orderCaptor=ArgumentCaptor.forClass(LastlegOrder.class);
|
||||
@@ -83,8 +82,7 @@ class LoadingCompletedConsumerTest {
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS");
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS");
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(v2));
|
||||
assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数"));
|
||||
@@ -97,8 +95,7 @@ class LoadingCompletedConsumerTest {
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0))
|
||||
.set("cartonMarks",json.valueToTree(List.of("LAX9-BOX-1","LAX9-BOX-2","LAX9-BOX-3")));
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
verify(cartons,never()).insert(any(InboundPlanCarton.class));
|
||||
@@ -108,8 +105,7 @@ class LoadingCompletedConsumerTest {
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA");
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA");
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
verify(cartons,never()).insert(any(InboundPlanCarton.class));
|
||||
@@ -124,7 +120,7 @@ class LoadingCompletedConsumerTest {
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8")));
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome());
|
||||
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture());
|
||||
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
|
||||
@@ -224,7 +220,7 @@ class LoadingCompletedConsumerTest {
|
||||
verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
|
||||
}
|
||||
private EventEnvelope rehash(EventEnvelope original,JsonNode node) {
|
||||
return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
|
||||
return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
|
||||
}
|
||||
private EventEnvelope overseasDropshipEvent(boolean duplicateSku) {
|
||||
EventEnvelope original=event();
|
||||
@@ -239,15 +235,15 @@ class LoadingCompletedConsumerTest {
|
||||
if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy());
|
||||
node.putArray("orders").add(sourceOrder);
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash);
|
||||
return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash);
|
||||
}
|
||||
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
||||
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
||||
@Test void projectsFiveDomesticLoadingPhotosOncePerPhysicalContainer(){
|
||||
EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(source);
|
||||
var values=node.putArray("loadingEvidence"); int fileId=10;
|
||||
for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg");
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome());
|
||||
ArgumentCaptor<com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence> captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class);
|
||||
verify(loadingEvidence,times(5)).insert(captured.capture());
|
||||
@@ -257,13 +253,13 @@ class LoadingCompletedConsumerTest {
|
||||
@Test void rejectsV3EventWithoutTheFiveDomesticLoadingPhotos(){
|
||||
EventEnvelope source=event(); JsonNode node=v2Payload(source);
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||
IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event));
|
||||
assertTrue(error.getMessage().contains("五类国内装柜照片"));
|
||||
verify(loadingEvidence,never()).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class));
|
||||
}
|
||||
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
|
||||
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
||||
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
||||
private void addManifest(JsonNode pallet,int packageCount){
|
||||
var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet;
|
||||
object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64));
|
||||
|
||||
+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.FirstLegContainerShippingNodeRecordedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.lastleg.entity.LastlegEventInbox;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventInboxMapper;
|
||||
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||
@@ -101,7 +100,7 @@ class ContainerShippingNodeConsumerTest {
|
||||
EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5,
|
||||
Instant.parse("2026-08-21T10:00:00Z"));
|
||||
EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
|
||||
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
|
||||
source.occurredAt(), source.tenantId(),
|
||||
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
|
||||
source.traceId(), source.correlationId(), source.causationId(), source.payload(),
|
||||
"sha256:" + "0".repeat(64));
|
||||
@@ -126,7 +125,7 @@ class ContainerShippingNodeConsumerTest {
|
||||
JsonNode payloadNode = json.valueToTree(payload);
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||
return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
|
||||
actualAt, RegionCode.CN, RegionCode.OVERSEAS, "0", "CONTAINER_BOOKING", "CB-1",
|
||||
actualAt, "0", "CONTAINER_BOOKING", "CB-1",
|
||||
version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null,
|
||||
payloadNode, hash);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user