This commit is contained in:
2026-09-29 18:47:37 +08:00
parent 2c60a29e0f
commit c2927ab9a6
291 changed files with 4008 additions and 6298 deletions
@@ -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));
}
}
@@ -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,
@@ -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) {
@@ -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)); }
}
@@ -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();
@@ -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()); }
}
@@ -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();
@@ -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)
@@ -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));
}
}
@@ -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
}
}
@@ -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());
}
}
}
@@ -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) { }
}
@@ -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>>() { });
}
}
@@ -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 }
}
@@ -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();
@@ -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());
@@ -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) {
@@ -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("资金结果事件载荷哈希无效");
@@ -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}
+35 -175
View File
@@ -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='本服务业务事件逐次投递审计';