From a7f680d69dd7f3c15ea0e26c441e3877ba12599b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 02:59:48 +0800 Subject: [PATCH] fix: restore transfer creation replay before carton validation --- .../service/WarehouseTransferService.java | 20 ++++---- .../service/WarehouseTransferServiceTest.java | 47 +++++++++++++++---- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index d18b0c51d..899052ff2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -214,6 +214,16 @@ public class WarehouseTransferService { @Transactional public WarehouseTransferResponses.Detail create(String customerId, String operatorId, WarehouseTransferRequests.Create request) { + // 已成功创建的命令先恢复原业务身份;不能用后来冻结或换标后的箱状态否定重放。 + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + WarehouseTransferOrder replay = transfers.findKey(customerId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency key was reused with different warehouse transfer data"); + } + return detail(customerId, replay.getTransferNo()); + } if (!request.isInboundModeValid()) throw badRequest("外来货物须填写入仓信息,库存转运须选择FBA仓或私人仓"); int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); if (itemPackages != request.packageCount()) { @@ -224,16 +234,6 @@ public class WarehouseTransferService { validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), request.privateDestinationContactPhone()); - String key = request.idempotencyKey().trim(); - String requestHash = hash(request); - WarehouseTransferOrder replay = transfers.findKey(customerId, key); - if (replay != null) { - if (!requestHash.equals(replay.getRequestHash())) { - throw conflict("Idempotency key was reused with different warehouse transfer data"); - } - return detail(customerId, replay.getTransferNo()); - } - var customer = customerDirectory.find(customerId); if (customer == null || !customerId.equals(customer.id()) || !StringUtils.hasText(customer.name())) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户名称暂不可用,请稍后重试"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 86125224d..33f365293 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -75,6 +75,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doAnswer; @@ -85,6 +86,24 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { + @Test + void creationReplayRestoresOriginalTransferBeforeRecheckingChangedCartonAvailability() { + var request = relabelCartonRequest("BOX-ORIGINAL"); + var existing = persistedTransfer(); + existing.setRequestHash(org.springframework.test.util.ReflectionTestUtils.invokeMethod(service,"hash",request)); + when(transfers.findKey("TENANT-A",request.idempotencyKey())).thenReturn(existing); + when(transfers.findNo("TENANT-A",existing.getTransferNo())).thenReturn(existing); + assertEquals(existing.getTransferNo(),service.create("TENANT-A","USER-1",request).transferNo()); + verifyNoInteractions(inboundSnapshots,customerDirectory,inboundOrders); + verify(orders,never()).insert(any(LastlegOrder.class)); + verify(orders,never()).updateById(any(LastlegOrder.class)); + verify(transfers,never()).insert(any(WarehouseTransferOrder.class)); + clearInvocations(orders); + existing.setRequestHash("different-original-payload"); + var conflict=assertThrows(ResponseStatusException.class,()->service.create("TENANT-A","USER-1",request)); + assertEquals(409,conflict.getStatusCode().value()); + verifyNoInteractions(inboundSnapshots,customerDirectory,inboundOrders,orders); + } @Test void detailIncludesOnlyTheCoordinatorScopedDurableReceiptProjection() { var transfer=transfer(WarehouseTransferService.PARTIALLY_RECEIVED); @@ -488,18 +507,13 @@ class WarehouseTransferServiceTest { assertEquals(400, exception.getStatusCode().value()); assertEquals("Warehouse transfer relabel only supports one destination warehouse per order", exception.getReason()); - verifyNoInteractions(transfers); + verify(transfers).findKey("TENANT-A",request.idempotencyKey()); + verifyNoMoreInteractions(transfers); } @Test void transferRelabelRejectsUnknownAndForeignOrderCartonsBeforePersistence() { - var request = new WarehouseTransferRequests.Create( - "IN-1", List.of(11L), "WH-1", null, "FBA", null, "ONT8", null, - null, null, null, null, null, null, null, 1, - BigDecimal.ZERO, null, null, null, null, null, null, null, true, - LocalDateTime.of(2026, 10, 16, 10, 0), "Contact", "123", null, "TRUCK", - "carton-ownership", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 1, null)), - List.of(new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("FOREIGN-BOX"), "SKU-1", null, 1, "FBA-1", "ONT8", null))); + var request = relabelCartonRequest("FOREIGN-BOX"); for (var cartons : List.of(List.of(), List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)))) { when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")).thenReturn(cartons); @@ -510,7 +524,8 @@ class WarehouseTransferServiceTest { .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("FOREIGN-BOX", "SKU-1", null, null, null, null, null))); org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "validateRelabelRequest", "TENANT-A", request)); - verifyNoInteractions(transfers); + verify(transfers,times(2)).findKey("TENANT-A",request.idempotencyKey()); + verifyNoMoreInteractions(transfers); } /** 先按原订单来源快照拒绝外部换标箱,不进入后续选箱占用。 */ @@ -546,7 +561,9 @@ class WarehouseTransferServiceTest { var error = assertThrows(ResponseStatusException.class, () -> service.create("TENANT-A","USER-1",request)); assertEquals(400,error.getStatusCode().value()); - verifyNoInteractions(transfers,orders); + verify(transfers).findKey("TENANT-A",request.idempotencyKey()); + verifyNoMoreInteractions(transfers); + verifyNoInteractions(orders); } @Test @@ -1279,6 +1296,16 @@ class WarehouseTransferServiceTest { verify(transfers, never()).insert(any(WarehouseTransferOrder.class)); } + private static WarehouseTransferRequests.Create relabelCartonRequest(String cartonMark) { + return new WarehouseTransferRequests.Create( + "IN-1", List.of(11L), "WH-1", null, "FBA", null, "ONT8", null, + null, null, null, null, null, null, null, 1, + BigDecimal.ZERO, null, null, null, null, null, null, null, true, + LocalDateTime.of(2026, 10, 16, 10, 0), "Contact", "123", null, "TRUCK", + "carton-ownership", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 1, null)), + List.of(new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of(cartonMark), "SKU-1", null, 1, "FBA-1", "ONT8", null))); + } + private static WarehouseTransferRequests.Create request(boolean needRelabel) { if (!needRelabel) { return new WarehouseTransferRequests.Create(