fix(lastleg): harden container plan saga recovery
This commit is contained in:
@@ -1995,12 +1995,14 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command (
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要',
|
||||
snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256',
|
||||
warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照',
|
||||
result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id),
|
||||
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at)
|
||||
) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT 'Warehouse权威实物托盘码';
|
||||
|
||||
SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message;
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ public class ContainerPlanConversionCommand {
|
||||
private Long warehouseId;
|
||||
private String batchKey;
|
||||
private String status;
|
||||
private String leaseToken;
|
||||
private String preparationJson;
|
||||
private String resultJson;
|
||||
private String lastError;
|
||||
|
||||
+21
-5
@@ -17,17 +17,33 @@ public interface ContainerPlanConversionCommandMapper extends BaseMapper<Contain
|
||||
@Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} LIMIT 1")
|
||||
ContainerPlanConversionCommand findByBooking(@Param("bookingId") String bookingId);
|
||||
|
||||
@Update("UPDATE ll_container_plan_conversion_command SET status=#{status},result_json=COALESCE(#{resultJson},result_json),last_error=#{lastError},updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}")
|
||||
@Update("UPDATE ll_container_plan_conversion_command SET status=#{status},result_json=COALESCE(#{resultJson},result_json),last_error=#{lastError},updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus} AND lease_token=#{leaseToken}")
|
||||
int updateState(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
|
||||
@Param("leaseToken") String leaseToken,
|
||||
@Param("status") String status,
|
||||
@Param("resultJson") String resultJson, @Param("lastError") String lastError,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
|
||||
@Update("UPDATE ll_container_plan_conversion_command SET status=#{nextStatus},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}")
|
||||
@Update("UPDATE ll_container_plan_conversion_command SET status=#{nextStatus},lease_token=#{leaseToken},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}")
|
||||
int claim(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
|
||||
@Param("nextStatus") String nextStatus, @Param("updatedAt") LocalDateTime updatedAt);
|
||||
@Param("nextStatus") String nextStatus, @Param("leaseToken") String leaseToken,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
|
||||
@Update("UPDATE ll_container_plan_conversion_command SET last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status='APPLYING' AND updated_at=#{expectedUpdatedAt}")
|
||||
@Update("UPDATE ll_container_plan_conversion_command SET lease_token=#{leaseToken},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status='APPLYING' AND updated_at=#{expectedUpdatedAt}")
|
||||
int reclaimStale(@Param("id") long id, @Param("expectedUpdatedAt") LocalDateTime expectedUpdatedAt,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
@Param("leaseToken") String leaseToken, @Param("updatedAt") LocalDateTime updatedAt);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_container_plan_conversion_command
|
||||
SET idempotency_key=#{key},request_hash=#{requestHash},snapshot_version=#{snapshotVersion},
|
||||
snapshot_hash=#{snapshotHash},warehouse_id=#{warehouseId},batch_key=#{batchKey},
|
||||
status='PREPARED',lease_token=NULL,preparation_json=#{preparationJson},result_json=NULL,last_error=NULL,
|
||||
updated_at=#{updatedAt}
|
||||
WHERE id=#{id} AND status='FAILED'
|
||||
""")
|
||||
int replaceFailed(@Param("id") long id, @Param("key") String key,
|
||||
@Param("requestHash") String requestHash, @Param("snapshotVersion") long snapshotVersion,
|
||||
@Param("snapshotHash") String snapshotHash, @Param("warehouseId") long warehouseId,
|
||||
@Param("batchKey") String batchKey, @Param("preparationJson") String preparationJson,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
}
|
||||
|
||||
+136
-48
@@ -46,82 +46,108 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空");
|
||||
String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符");
|
||||
String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash()));
|
||||
ContainerPlanConversionCommand resolved = commands.find(bookingId, key);
|
||||
if (resolved == null) resolved = commands.findByBooking(bookingId);
|
||||
if (resolved == null) resolved = createCommand(bookingId, key, request, requestHash);
|
||||
ContainerPlanConversionCommand direct = commands.find(bookingId, key);
|
||||
ContainerPlanConversionCommand resolved = direct == null ? commands.findByBooking(bookingId) : direct;
|
||||
if (resolved == null) {
|
||||
resolved = createCommand(bookingId, key, request, requestHash);
|
||||
} else if ("COMPLETED".equals(resolved.getStatus())) {
|
||||
if (direct != null) requireSameRequest(resolved, requestHash);
|
||||
return result(resolved);
|
||||
} else if ("FAILED".equals(resolved.getStatus())) {
|
||||
resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash);
|
||||
} else {
|
||||
requireSameRequest(resolved, requestHash);
|
||||
}
|
||||
if ("COMPLETED".equals(resolved.getStatus())) {
|
||||
requireSameRequest(resolved, requestHash);
|
||||
return result(resolved);
|
||||
}
|
||||
final ContainerPlanConversionCommand command = resolved;
|
||||
requireSameRequest(command, requestHash);
|
||||
Preparation preparation = read(command.getPreparationJson(), Preparation.class);
|
||||
|
||||
if ("COMPLETED".equals(command.getStatus())) return result(command);
|
||||
boolean applyingLeaseOwned = false;
|
||||
if ("APPLYING".equals(command.getStatus())) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String leaseToken = IdWorker.getIdStr();
|
||||
if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now)
|
||||
|| commands.reclaimStale(command.getId(), command.getUpdatedAt(), now) != 1) {
|
||||
|| commands.reclaimStale(command.getId(), command.getUpdatedAt(), leaseToken, now) != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
|
||||
}
|
||||
command.setLeaseToken(leaseToken);
|
||||
command.setUpdatedAt(now);
|
||||
applyingLeaseOwned = true;
|
||||
}
|
||||
if (!"LOCAL_COMMITTED".equals(command.getStatus())) {
|
||||
if (commands.claim(command.getId(), "PREPARED", "APPLYING", LocalDateTime.now()) != 1) {
|
||||
if ("PREPARED".equals(command.getStatus())) {
|
||||
String leaseToken = IdWorker.getIdStr();
|
||||
if (commands.claim(command.getId(), "PREPARED", "APPLYING", leaseToken, LocalDateTime.now()) != 1) {
|
||||
ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
|
||||
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
|
||||
}
|
||||
command.setStatus("APPLYING");
|
||||
command.setLeaseToken(leaseToken);
|
||||
applyingLeaseOwned = true;
|
||||
}
|
||||
if (!applyingLeaseOwned && !"LOCAL_COMMITTED".equals(command.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级转换命令状态无效");
|
||||
}
|
||||
if (preparation.items().isEmpty()) {
|
||||
try {
|
||||
List<InboundPlanResponse> existing = inTransaction(() -> {
|
||||
List<InboundPlanResponse> converted = plans.convertContainerReserved(bookingId,
|
||||
request.snapshotVersion(), request.snapshotHash(), List.of());
|
||||
state(command.getId(), "APPLYING", "COMPLETED", write(converted), null);
|
||||
state(command, "APPLYING", "COMPLETED", write(converted), null);
|
||||
return converted;
|
||||
});
|
||||
command.setStatus("COMPLETED");
|
||||
return existing;
|
||||
} catch (RuntimeException failure) {
|
||||
resetForRetry(command, failure);
|
||||
ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
|
||||
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
|
||||
failWithoutResources(command, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
|
||||
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
|
||||
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), item.businessKey(),
|
||||
item.planNo(), item.globalSequenceNo(), "STANDARD", item.fbaWarehouseCode())).toList());
|
||||
WarehouseResourceGateway.PalletBatchResponse reservation;
|
||||
try {
|
||||
reservation = warehouse.ensurePalletBatch(ensure);
|
||||
requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED");
|
||||
} catch (RuntimeException failure) {
|
||||
resetForRetry(command, failure);
|
||||
throw failure;
|
||||
}
|
||||
|
||||
List<InboundPlanResponse> localResult;
|
||||
if ("LOCAL_COMMITTED".equals(command.getStatus())) {
|
||||
localResult = result(command);
|
||||
} else {
|
||||
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
|
||||
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
|
||||
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
|
||||
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
|
||||
item.fbaWarehouseCode())).toList());
|
||||
WarehouseResourceGateway.PalletBatchResponse reservation;
|
||||
try {
|
||||
reservation = warehouse.ensurePalletBatch(ensure);
|
||||
requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED");
|
||||
} catch (RuntimeException failure) {
|
||||
compensateAndFail(command, preparation, failure);
|
||||
throw failure;
|
||||
}
|
||||
try {
|
||||
List<InboundPlanService.PreparedPlan> preparedPlans = bind(preparation, reservation);
|
||||
localResult = inTransaction(() -> {
|
||||
List<InboundPlanResponse> created = plans.convertContainerReserved(bookingId,
|
||||
request.snapshotVersion(), request.snapshotHash(), preparedPlans);
|
||||
state(command.getId(), "APPLYING", "LOCAL_COMMITTED", write(created), null);
|
||||
state(command, "APPLYING", "LOCAL_COMMITTED", write(created), null);
|
||||
return created;
|
||||
});
|
||||
command.setStatus("LOCAL_COMMITTED");
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
warehouse.compensatePalletBatch(new WarehouseResourceGateway.PalletBatchCompensation(
|
||||
preparation.warehouseId(), command.getBatchKey(), "COMPENSATE:" + command.getId(),
|
||||
"Lastleg local transaction did not commit"));
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
failure.addSuppressed(compensationFailure);
|
||||
ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
|
||||
if (current != null && "COMPLETED".equals(current.getStatus())) {
|
||||
return result(current);
|
||||
}
|
||||
if (current != null && "LOCAL_COMMITTED".equals(current.getStatus())) {
|
||||
command.setStatus(current.getStatus());
|
||||
command.setResultJson(current.getResultJson());
|
||||
localResult = result(current);
|
||||
} else {
|
||||
compensateAndFail(command, preparation, failure);
|
||||
throw failure;
|
||||
}
|
||||
resetForRetry(command, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +162,35 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
private ContainerPlanConversionCommand createCommand(String bookingId, String key,
|
||||
ConvertContainerInboundPlanRequest request,
|
||||
String requestHash) {
|
||||
long commandId = IdWorker.getId();
|
||||
ContainerPlanConversionCommand command = prepareCommand(commandId, commandId, bookingId, key, request, requestHash);
|
||||
try { inTransaction(() -> { commands.insert(command); return null; }); }
|
||||
catch (DuplicateKeyException concurrent) {
|
||||
ContainerPlanConversionCommand existing = commands.findByBooking(bookingId);
|
||||
if (existing == null) throw concurrent;
|
||||
return existing;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
private ContainerPlanConversionCommand replaceFailedCommand(ContainerPlanConversionCommand previous,
|
||||
String bookingId, String key,
|
||||
ConvertContainerInboundPlanRequest request,
|
||||
String requestHash) {
|
||||
ContainerPlanConversionCommand replacement = prepareCommand(previous.getId(), IdWorker.getId(),
|
||||
bookingId, key, request, requestHash);
|
||||
int updated = commands.replaceFailed(previous.getId(), key, requestHash, request.snapshotVersion(),
|
||||
request.snapshotHash(), replacement.getWarehouseId(), replacement.getBatchKey(),
|
||||
replacement.getPreparationJson(), replacement.getUpdatedAt());
|
||||
if (updated != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在刷新入库计划,请勿重复提交");
|
||||
}
|
||||
return replacement;
|
||||
}
|
||||
|
||||
private ContainerPlanConversionCommand prepareCommand(long commandId, long generationId, String bookingId,
|
||||
String key, ConvertContainerInboundPlanRequest request,
|
||||
String requestHash) {
|
||||
ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId);
|
||||
if (preview.snapshotVersion() != request.snapshotVersion()
|
||||
|| !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) {
|
||||
@@ -146,7 +201,6 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
catch (RuntimeException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
|
||||
}
|
||||
long commandId = IdWorker.getId();
|
||||
List<PreparedItem> items = new ArrayList<>();
|
||||
int globalSequence = 0;
|
||||
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
|
||||
@@ -155,7 +209,7 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
|
||||
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
|
||||
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
|
||||
localSequence, ++globalSequence, "CIP:" + commandId + ":" + globalSequence,
|
||||
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
|
||||
"FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null));
|
||||
}
|
||||
}
|
||||
@@ -164,15 +218,9 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key);
|
||||
command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion());
|
||||
command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId);
|
||||
command.setBatchKey("CIP:" + hash(bookingId) + ":" + commandId); command.setStatus("PREPARED");
|
||||
command.setBatchKey("CIP:" + hash(bookingId) + ":" + generationId); command.setStatus("PREPARED");
|
||||
command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now());
|
||||
command.setUpdatedAt(command.getCreatedAt());
|
||||
try { inTransaction(() -> { commands.insert(command); return null; }); }
|
||||
catch (DuplicateKeyException concurrent) {
|
||||
ContainerPlanConversionCommand existing = commands.findByBooking(bookingId);
|
||||
if (existing == null) throw concurrent;
|
||||
return existing;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
@@ -213,11 +261,26 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
|
||||
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
|
||||
String... acceptedStatuses) {
|
||||
if (response == null || !Objects.equals(batchKey, response.batchKey()) || response.items() == null
|
||||
if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
|
||||
|| !Objects.equals(batchKey, response.batchKey()) || response.items() == null
|
||||
|| response.items().size() != size || java.util.Arrays.stream(acceptedStatuses)
|
||||
.noneMatch(status -> status.equals(response.status()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致");
|
||||
}
|
||||
String expectedItemStatus = switch (response.status()) {
|
||||
case "RESERVED" -> "RESERVED";
|
||||
case "CONFIRMED" -> "CONFIRMED";
|
||||
case "COMPENSATED" -> "RELEASED";
|
||||
default -> null;
|
||||
};
|
||||
boolean invalidItem = expectedItemStatus == null || response.items().stream().anyMatch(item ->
|
||||
item.customerId() <= 0 || item.sequenceNo() <= 0 || item.palletId() <= 0 || item.locationId() <= 0
|
||||
|| item.resourceLockId() <= 0 || trimToNull(item.businessKey()) == null
|
||||
|| trimToNull(item.inboundPlanNo()) == null || trimToNull(item.palletCode()) == null
|
||||
|| item.palletCode().length() > 128 || !expectedItemStatus.equals(item.status()));
|
||||
if (invalidItem) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次明细不完整或状态不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) {
|
||||
@@ -235,19 +298,41 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
}
|
||||
|
||||
private void complete(ContainerPlanConversionCommand command, List<InboundPlanResponse> result) {
|
||||
inTransaction(() -> { state(command.getId(), "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; });
|
||||
inTransaction(() -> { state(command, "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; });
|
||||
command.setStatus("COMPLETED");
|
||||
}
|
||||
|
||||
private void resetForRetry(ContainerPlanConversionCommand command, RuntimeException failure) {
|
||||
private void failWithoutResources(ContainerPlanConversionCommand command, RuntimeException failure) {
|
||||
if ("APPLYING".equals(command.getStatus())) {
|
||||
state(command.getId(), "APPLYING", "PREPARED", null, safeMessage(failure));
|
||||
command.setStatus("PREPARED");
|
||||
state(command, "APPLYING", "FAILED", null, safeMessage(failure));
|
||||
command.setStatus("FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private void state(long id, String expectedStatus, String status, String resultJson, String lastError) {
|
||||
if (commands.updateState(id, expectedStatus, status, resultJson, lastError, LocalDateTime.now()) != 1) {
|
||||
private void compensateAndFail(ContainerPlanConversionCommand command, Preparation preparation,
|
||||
RuntimeException failure) {
|
||||
try {
|
||||
WarehouseResourceGateway.PalletBatchResponse compensated = warehouse.compensatePalletBatch(
|
||||
new WarehouseResourceGateway.PalletBatchCompensation(preparation.warehouseId(),
|
||||
command.getBatchKey(), "COMPENSATE:" + command.getBatchKey(),
|
||||
"Lastleg local transaction did not commit"));
|
||||
requireBatch(compensated, command.getBatchKey(), preparation.items().size(), "COMPENSATED");
|
||||
failWithoutResources(command, failure);
|
||||
} catch (ResponseStatusException compensationFailure) {
|
||||
if (compensationFailure.getStatusCode().value() == 404) {
|
||||
failWithoutResources(command, failure);
|
||||
return;
|
||||
}
|
||||
failure.addSuppressed(compensationFailure);
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
failure.addSuppressed(compensationFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void state(ContainerPlanConversionCommand command, String expectedStatus, String status,
|
||||
String resultJson, String lastError) {
|
||||
if (commands.updateState(command.getId(), expectedStatus, command.getLeaseToken(), status,
|
||||
resultJson, lastError, LocalDateTime.now()) != 1) {
|
||||
throw new IllegalStateException("无法更新柜级转换命令状态");
|
||||
}
|
||||
}
|
||||
@@ -281,6 +366,9 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
String message = value.getMessage();
|
||||
return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length()));
|
||||
}
|
||||
private static String trimToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
public record Preparation(long warehouseId, List<PreparedItem> items) { }
|
||||
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
|
||||
|
||||
+2
-1
@@ -198,7 +198,8 @@ public class InboundPlanService {
|
||||
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0);
|
||||
String snapshotHash = sha256(List.of(bookingId, containerNo, warehouseId, snapshotVersion, orders));
|
||||
String snapshotHash = sha256(java.util.Arrays.asList(
|
||||
bookingId, containerNo, warehouseId, snapshotVersion, orders));
|
||||
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
|
||||
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
|
||||
pendingOrders, pendingPallets, existingPallets,
|
||||
|
||||
@@ -587,17 +587,20 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command (
|
||||
snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256',
|
||||
warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID',
|
||||
batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'PREPARED/APPLYING/LOCAL_COMMITTED/COMPLETED',
|
||||
status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED',
|
||||
lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离',
|
||||
preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照',
|
||||
result_json JSON NULL COMMENT '本地计划提交结果',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id),
|
||||
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key),
|
||||
KEY idx_ll_container_conversion_status(status,updated_at)
|
||||
) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status'));
|
||||
PREPARE container_conversion_lease_stmt FROM @ddl; EXECUTE container_conversion_lease_stmt; DEALLOCATE PREPARE container_conversion_lease_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
|
||||
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
|
||||
|
||||
+142
-1
@@ -1,9 +1,11 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
|
||||
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -11,8 +13,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -20,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.any;
|
||||
import static org.mockito.Mockito.eq;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ContainerInboundPlanConversionCoordinatorTest {
|
||||
@@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
@Mock InboundPlanService plans;
|
||||
@Mock WarehouseResourceGateway warehouse;
|
||||
@Mock ContainerPlanConversionCommandMapper commands;
|
||||
@Mock LastlegOrderNoGenerator numbers;
|
||||
@Mock PlatformTransactionManager transactionManager;
|
||||
|
||||
private ContainerInboundPlanConversionCoordinator coordinator;
|
||||
private JsonMapper objectMapper;
|
||||
@@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
ReflectionTestUtils.setField(coordinator, "plans", plans);
|
||||
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(coordinator, "commands", commands);
|
||||
ReflectionTestUtils.setField(coordinator, "numbers", numbers);
|
||||
ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper);
|
||||
ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager);
|
||||
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any()))
|
||||
.thenReturn(new SimpleTransactionStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyStillRejectsAlteredCompletedRequest() throws Exception {
|
||||
ContainerPlanConversionCommand completed = command("FIRST-KEY", "COMPLETED", request());
|
||||
completed.setResultJson("[]");
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(completed);
|
||||
|
||||
ConvertContainerInboundPlanRequest changed =
|
||||
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64));
|
||||
|
||||
assertEquals(409, assertThrows(ResponseStatusException.class,
|
||||
() -> coordinator.confirm(changed, "FIRST-KEY")).getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
@@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null);
|
||||
when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared);
|
||||
when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"),
|
||||
org.mockito.ArgumentMatchers.eq("APPLYING"), org.mockito.ArgumentMatchers.any())).thenReturn(0);
|
||||
org.mockito.ArgumentMatchers.eq("APPLYING"), any(String.class), org.mockito.ArgumentMatchers.any())).thenReturn(0);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> coordinator.confirm(request, "SECOND-KEY"));
|
||||
@@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleApplyingLeaseIsReclaimedWithoutRepeatingPreparedClaim() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand stale = command("FIRST-KEY", "APPLYING", request);
|
||||
stale.setUpdatedAt(LocalDateTime.now().minusMinutes(3));
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(stale);
|
||||
when(commands.reclaimStale(eq(1L), eq(stale.getUpdatedAt()), any(String.class), any())).thenReturn(1);
|
||||
when(plans.convertContainerReserved(BOOKING_ID, 7L, SNAPSHOT_HASH, List.of())).thenReturn(List.of());
|
||||
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
|
||||
|
||||
verify(commands, never()).claim(any(Long.class), any(String.class), any(String.class),
|
||||
any(String.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensatedFailureCanBeReplacedByFreshSnapshotAndComplete() throws Exception {
|
||||
ContainerPlanConversionCommand failed = command("OLD-KEY", "FAILED", request());
|
||||
when(commands.find(BOOKING_ID, "NEW-KEY")).thenReturn(null);
|
||||
when(commands.findByBooking(BOOKING_ID)).thenReturn(failed);
|
||||
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64)));
|
||||
when(commands.replaceFailed(eq(1L), eq("NEW-KEY"), any(), eq(8L), eq("b".repeat(64)),
|
||||
eq(31L), any(), any(), any())).thenReturn(1);
|
||||
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
|
||||
when(plans.convertContainerReserved(BOOKING_ID, 8L, "b".repeat(64), List.of())).thenReturn(List.of());
|
||||
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(
|
||||
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)), "NEW-KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidEnsureResponseIsCompensatedBeforeCommandBecomesFailed() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
|
||||
setOnePreparedItem(prepared);
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
|
||||
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
|
||||
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
0L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
|
||||
when(warehouse.compensatePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "COMPENSATED", 1, false, List.of(reserved("RELEASED"))));
|
||||
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("FAILED"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(409, assertThrows(ResponseStatusException.class,
|
||||
() -> coordinator.confirm(request, "FIRST-KEY")).getStatusCode().value());
|
||||
|
||||
verify(warehouse).compensatePalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uncertainLocalCommitIsRereadBeforeDecidingWhetherToCompensate() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
|
||||
setOnePreparedItem(prepared);
|
||||
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
|
||||
committed.setResultJson("[]");
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
|
||||
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
|
||||
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
|
||||
when(plans.convertContainerReserved(eq(BOOKING_ID), eq(7L), eq(SNAPSHOT_HASH), any()))
|
||||
.thenThrow(new IllegalStateException("commit outcome unknown"));
|
||||
when(commands.findByBooking(BOOKING_ID)).thenReturn(committed);
|
||||
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
|
||||
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
|
||||
|
||||
verify(warehouse, never()).compensatePalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void localCommittedReplayOnlyRetriesRemoteConfirmation() throws Exception {
|
||||
ConvertContainerInboundPlanRequest request = request();
|
||||
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
|
||||
committed.setResultJson("[]");
|
||||
setOnePreparedItem(committed);
|
||||
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed);
|
||||
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
|
||||
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
|
||||
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
|
||||
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
|
||||
|
||||
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
|
||||
|
||||
verify(warehouse, never()).ensurePalletBatch(any());
|
||||
verify(warehouse).confirmPalletBatch(any());
|
||||
}
|
||||
|
||||
private ConvertContainerInboundPlanRequest request() {
|
||||
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
|
||||
}
|
||||
@@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
||||
command.setWarehouseId(31L);
|
||||
command.setBatchKey("BATCH-1");
|
||||
command.setStatus(status);
|
||||
if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1");
|
||||
command.setPreparationJson(objectMapper.writeValueAsString(
|
||||
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of())));
|
||||
return command;
|
||||
}
|
||||
|
||||
private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception {
|
||||
var item = new ContainerInboundPlanConversionCoordinator.PreparedItem(
|
||||
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8");
|
||||
command.setPreparationJson(objectMapper.writeValueAsString(
|
||||
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item))));
|
||||
}
|
||||
|
||||
private WarehouseResourceGateway.PalletBatchItem reserved(String status) {
|
||||
return new WarehouseResourceGateway.PalletBatchItem(
|
||||
100L, "CIP:1:1", "IP-21", 1, 51L, "PLT-51", 61L, 71L, status);
|
||||
}
|
||||
|
||||
private ContainerInboundPlanPreviewResponse preview(long version, String hash) {
|
||||
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||
"Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -67,6 +67,21 @@ class ContainerInboundPlanPreviewTest {
|
||||
assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() {
|
||||
when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of(
|
||||
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
|
||||
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
|
||||
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
|
||||
null, null, null)));
|
||||
|
||||
var result = service.previewContainer("BOOKING-NULL");
|
||||
|
||||
assertNotNull(result.snapshotHash());
|
||||
assertEquals(64, result.snapshotHash().length());
|
||||
assertFalse(result.ready());
|
||||
}
|
||||
|
||||
private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
|
||||
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
|
||||
int recommendedPallets, String planNo, Integer plannedPallets, int version) {
|
||||
|
||||
Reference in New Issue
Block a user