From 57a2f6ef28cb3e80b0c8c13666c5cc6ec7f914ca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 15:44:05 +0800 Subject: [PATCH] fix(firstleg): enforce strict manual node progression --- ...latformCarrierScheduleQueryController.java | 2 +- .../service/CarrierScheduleQueryService.java | 31 +++------------ .../openapi/carrier-schedule-query-v1.yaml | 6 +-- ...rrierScheduleQueryOpenApiContractTest.java | 5 +-- .../CarrierScheduleQueryServiceTest.java | 38 +++++-------------- 5 files changed, 22 insertions(+), 60 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 025e591d1..4b76ab3a0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -98,7 +98,7 @@ public class PlatformCarrierScheduleQueryController { @PostMapping("/{queryId}/manual-nodes") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @Operation(summary = "人工补录预计或实际标准船节点", - description = "预计节点不推进实际进度;同级当前节点可追加审计事实并修正实际时间,更早节点返回 A0409。") + description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", resourceId = "#queryId", description = "人工补录船节点事实") public ApiResponse manualNode(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 7786ab362..417ab2d60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -205,7 +205,7 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); - requireManualActualNotEarlier(value, node, request.actualAt()); + requireManualActualAdvance(value, node, request.actualAt()); CarrierScheduleQueryEntity before = copy(value); String providerEventId = sha256("MANUAL|" + id + '|' + key); CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node, @@ -215,7 +215,7 @@ public class CarrierScheduleQueryService { if (fact == null) { return complete(id, operatorId, key, detail(value)); } - boolean advanced = advanceManualActualNode(value, node, request.actualAt(), request.observedAt(), + boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), operatorId, operatorName); if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) { throw concurrent(); @@ -227,34 +227,15 @@ public class CarrierScheduleQueryService { return complete(id, operatorId, key, detail(after)); } - private void requireManualActualNotEarlier(CarrierScheduleQueryEntity value, CarrierScheduleNode node, - LocalDateTime actualAt) { + private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt) { if (actualAt == null || value.getLatestStandardNode() == null) return; CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode()); - if (node.sequence() < current.sequence()) { - throw conflict("人工实际节点不得早于当前最新实际节点:" + current.name()); + if (node.sequence() <= current.sequence()) { + throw conflict("人工实际节点必须严格晚于当前最新实际节点"); } } - private boolean advanceManualActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, - LocalDateTime actualAt, LocalDateTime observedAt, - long operatorId, String operatorName) { - if (actualAt == null) return false; - CarrierScheduleNode current = value.getLatestStandardNode() == null ? null - : CarrierScheduleNode.require(value.getLatestStandardNode()); - if (current == node) { - value.setLatestNodeObservedAt(actualAt); value.setLastSuccessAt(now()); - value.setSourceUpdatedAt(observedAt); value.setLastAttemptStatus("SUCCESS"); - value.setLastErrorCode(null); value.setLastErrorMessage(null); - if (!"STOPPED".equals(value.getStatus())) { - value.setStatus("READY"); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now())); - } - if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent(); - return true; - } - return advanceActualNode(value, node, actualAt, observedAt, operatorId, operatorName); - } - private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, LocalDateTime actualAt, LocalDateTime observedAt, long operatorId, String operatorName) { diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 0626f254d..5fac3399f 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -82,9 +82,9 @@ paths: schema: {$ref: '#/components/schemas/ManualNodeRequest'} responses: '200': - description: Idempotent fact append; estimated facts never advance actual progress; the current latestActualNode may append an audited correction fact and replace latestActualAt; a later actual node advances progress and actual EMPTY_RETURNED stops + description: Idempotent fact append; estimated facts never advance actual progress, while a manual actual fact must have a strictly greater standard-node sequence than latestActualNode and actual EMPTY_RETURNED stops '409': - description: A0409 when expectedVersion is stale or a manual actual node is earlier than latestActualNode + description: A0409 when expectedVersion is stale or a manual actual node is equal to or earlier than latestActualNode content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} components: parameters: @@ -124,7 +124,7 @@ components: ManualNodeRequest: type: object required: [expectedVersion, standardNode, observedAt, reason] - description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode may equal latestActualNode to correct latestActualAt through a new immutable audit fact, but cannot be earlier. + description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode must have a strictly greater sequence than latestActualNode. properties: expectedVersion: {type: integer, minimum: 0} standardNode: {$ref: '#/components/schemas/StandardNode'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index cb9470c71..9c3fccd91 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -24,9 +24,8 @@ class CarrierScheduleQueryOpenApiContractTest { .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") .contains("latestActualNode:") .contains("latestActualAt:") - .contains("current latestActualNode may append an audited correction fact") - .contains("replace latestActualAt") - .contains("cannot be earlier") + .contains("manual actual node is equal to or earlier than latestActualNode") + .contains("strictly greater sequence than latestActualNode") .contains("internal execution fields are intentionally absent") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("/{queryId}/attempts:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 49386098b..78b425d42 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -293,43 +293,25 @@ class CarrierScheduleQueryServiceTest { } @Test - void manualActualMayCorrectCurrentNodeTimeThroughNewImmutableFact() { - CarrierScheduleQueryEntity before = task(0, 3); - before.setStatus("READY"); before.setLatestStandardNode("PICKED_UP"); - before.setLatestNodeObservedAt(NOW.minusDays(1)); - CarrierScheduleQueryEntity after = task(0, 4); - after.setStatus("READY"); after.setLatestStandardNode("PICKED_UP"); - after.setLatestNodeObservedAt(NOW.minusDays(2)); - when(repository.lock(1L)).thenReturn(before); when(repository.find(1L)).thenReturn(after); - when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); - when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); - - var result = service.addManualNode(1L, 7L, "admin", "manual-picked-up-correction", - new ManualNodeRequest(3, "PICKED_UP", NOW, null, NOW.minusDays(2), null, - "Los Angeles", "修正提柜时间")); - - assertThat(result.query().query().latestActualNode()).isEqualTo("PICKED_UP"); - assertThat(result.query().query().latestActualAt()).isEqualTo(NOW.minusDays(2)); - ArgumentCaptor fact = - ArgumentCaptor.forClass(CarrierScheduleNodeFactEntity.class); - verify(repository).insertIfAbsent(fact.capture()); - assertThat(fact.getValue().getActualAt()).isEqualTo(NOW.minusDays(2)); - verify(repository).updateState(before, 3, 7L, "admin"); - verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-picked-up-correction"), anyString()); - } - - @Test - void manualActualRejectsEarlierStandardNodeWithoutAppendingFact() { + void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() { CarrierScheduleQueryEntity task = task(0, 3); task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); task.setLatestNodeObservedAt(NOW.minusDays(1)); when(repository.lock(1L)).thenReturn(task); + ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null, + NOW.plusDays(1), null, "Los Angeles", "重复确认提柜"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode)) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode)) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late", new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null, "Los Angeles", "倒序补录可提柜"))) .isInstanceOf(BizException.class) - .hasMessageContaining("不得早于当前最新实际节点"); + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());