diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 6d190934a..b572fe868 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -173,6 +173,10 @@ public class ContainerCustomsService { int expectedVersion, boolean finalConfirmation, MultipartFile file) { validateFile(file); DocumentRow readable=requiredDocument(documentId); + String action = "UPLOAD_COMPLETION_" + documentId; + String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file)); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return documentView(requiredDocument(documentId), false); ContainerRow container = mapper.lockContainerById(readable.customsContainerId()); DocumentRow source = requiredDocumentLocked(documentId, null); if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) { @@ -183,10 +187,6 @@ public class ContainerCustomsService { boolean lastNewCompletion=completion==null&&missingBefore==1; if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传"); if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成"); - String action = "UPLOAD_COMPLETION_" + documentId; - String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file)); - CommandRow replay = claim(operatorId, action, key, requestHash); - if (replay != null) return documentView(requiredDocument(documentId), false); CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); long completionId; int version; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index c9c3ca36f..b7e74f912 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -106,6 +106,7 @@ class ContainerCustomsServiceTest { @Test void lastCompletionRequiresConfirmationBeforeCallingFile(){ var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); + when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1); when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container); when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null); when(mapper.missingCompletionCount(2L)).thenReturn(1); @@ -117,6 +118,7 @@ class ContainerCustomsServiceTest { @Test void completedContainerLocksCompletionReplacement(){ var source=document(1L);when(mapper.document(1L)).thenReturn(source); + when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1); when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.COMPLETED,4)); when(mapper.lockDocument(1L)).thenReturn(source); var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); @@ -125,6 +127,65 @@ class ContainerCustomsServiceTest { verifyNoInteractions(files); } + @Test void finalCompletionReplaySucceedsAfterContainerCompletedAndRejectsChangedPayload(){ + var source=document(1L); + var declaring=container(ContainerCustomsService.DECLARING,3); + var completed=container(ContainerCustomsService.COMPLETED,4); + var containerState=new AtomicReference<>(declaring); + var completionState=new AtomicReference(); + var commandState=new AtomicReference(); + var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1}); + var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2}); + var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RESULT","9","result.pdf", + "b".repeat(64),"application/pdf",1L); + when(mapper.document(1L)).thenReturn(source); + when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString())) + .thenAnswer(invocation->{ + if(commandState.get()!=null)return 0; + commandState.set(new ContainerCustomsMapper.CommandRow(invocation.getArgument(0),5L, + "UPLOAD_COMPLETION_1","completion-key",invocation.getArgument(4),"PROCESSING",null,null,null)); + return 1; + }); + when(mapper.command(5L,"UPLOAD_COMPLETION_1","completion-key")).thenAnswer(invocation->commandState.get()); + when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get()); + when(mapper.lockDocument(1L)).thenReturn(source); + when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get()); + when(mapper.missingCompletionCount(2L)).thenReturn(1); + when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file))) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata)); + when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)), + eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{ + completionState.set(new ContainerCustomsMapper.CompletionRow(invocation.getArgument(0),1L,99L, + "result.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1)); + return 1; + }); + when(mapper.complete(2L,3,5L,"admin")).thenAnswer(invocation->{containerState.set(completed);return 1;}); + when(mapper.completeCommand(eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyLong())) + .thenAnswer(invocation->{ + var current=commandState.get(); + commandState.set(new ContainerCustomsMapper.CommandRow(current.id(),current.actorId(),current.action(), + current.idempotencyKey(),current.requestHash(),"SUCCESS",invocation.getArgument(3),null,null)); + return 1; + }); + when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I"))); + when(mapper.completion(1L)).thenAnswer(invocation->completionState.get()); + when(mapper.containerById(2L)).thenAnswer(invocation->containerState.get()); + + var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,true,file); + var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,true,file); + + assertThat(first.completion().fileId()).isEqualTo(99L); + assertThat(replay.completion().fileId()).isEqualTo(99L); + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,true,changedFile)) + .isInstanceOfSatisfying(BizException.class, + error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); + verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)); + verify(mapper,times(1)).lockContainerById(2L); + verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(), + eq(1L),eq(5L),eq("admin")); + verify(mapper,times(1)).complete(2L,3,5L,"admin"); + } + @Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){ var source=document(1L);when(mapper.document(1L)).thenReturn(source); when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));