From 8350bcdf00f8291a682930cc740ac0474f8c24c9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 17:27:26 +0800 Subject: [PATCH] fix: replay preload confirmation idempotently --- .../firstleg/service/LoadingListService.java | 23 ++++++-- .../service/LoadingListServiceTest.java | 52 ++++++++++++++++++- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 40916e5ca..626caa1b4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -24,6 +24,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; import com.baiyue.firstleg.repository.LoadingListRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -553,17 +554,29 @@ public class LoadingListService { return "PL-" + sha256(operation + '|' + planId + '|' + normalized); } - private static void requireSameRequest(HistoryRow replay, String requestHash) { - String snapshot = replay.getAfterSnapshot(); - if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) { + private JsonNode requireSameRequest(HistoryRow replay, String requestHash) { + String rawSnapshot = replay.getAfterSnapshot(); + if (!StringUtils.hasText(rawSnapshot)) { throw conflict("Idempotency-Key 已用于不同请求"); } + try { + JsonNode snapshot = objectMapper.readTree(rawSnapshot); + JsonNode storedHash = snapshot == null ? null : snapshot.get("requestHash"); + if (storedHash == null || !storedHash.isTextual() + || !MessageDigest.isEqual(storedHash.textValue().getBytes(StandardCharsets.UTF_8), + requestHash.getBytes(StandardCharsets.UTF_8))) { + throw conflict("Idempotency-Key 已用于不同请求"); + } + return snapshot; + } catch (JsonProcessingException exception) { + throw new IllegalStateException("无法读取装柜幂等快照", exception); + } } private PreloadConfirmationResponse confirmationReplay(HistoryRow replay, String requestHash) { - requireSameRequest(replay, requestHash); + JsonNode snapshot = requireSameRequest(replay, requestHash); try { - var response = objectMapper.readTree(replay.getAfterSnapshot()).get("response"); + JsonNode response = snapshot.get("response"); if (response == null) throw new IllegalStateException("预装确认幂等响应缺失"); return objectMapper.treeToValue(response, PreloadConfirmationResponse.class); } catch (JsonProcessingException exception) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 8fd023afa..8aaf35b37 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -541,8 +541,9 @@ class LoadingListServiceTest { 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); String requestHash = sha256("CONFIRM_PRELOAD|10|3"); HistoryRow replay = new HistoryRow(); - replay.setAfterSnapshot(new ObjectMapper().writeValueAsString( - java.util.Map.of("requestHash", requestHash, "response", expected))); + ObjectMapper mapper = new ObjectMapper(); + replay.setAfterSnapshot(mapper.writerWithDefaultPrettyPrinter().writeValueAsString( + java.util.Map.of("response", expected, "requestHash", requestHash))); when(repository.history(anyString())).thenReturn(replay); PreloadConfirmationResponse actual = service.confirmPreload( @@ -552,6 +553,53 @@ class LoadingListServiceTest { verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); verify(repository, never()).touchForPreloadConfirmation(anyLong(), org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(), + org.mockito.ArgumentMatchers.isNull()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + + @Test + void rejectsConfirmationReplayWhenExpectedVersionDiffers() throws Exception { + PreloadConfirmationResponse expected = new PreloadConfirmationResponse( + 10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L)); + String originalHash = sha256("CONFIRM_PRELOAD|10|3"); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot(new ObjectMapper().writeValueAsString( + java.util.Map.of("requestHash", originalHash, "response", expected))); + when(repository.history(anyString())).thenReturn(replay); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong()); + } + + @Test + void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() { + AggregateRow confirmed = row("EDITING", 4); + confirmed.setPreloadPushedAt(java.time.LocalDateTime.now()); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(confirmed); + when(loadingPickService.requirePreloadConfirmationReady(10L)) + .thenThrow(new BizException(ResultCode.CONFLICT, "预装计划已确认")); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "new-confirm-key", new ConfirmPreloadRequest(4))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), + org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString()); + verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), anyLong(), anyString(), + org.mockito.ArgumentMatchers.isNull()); } @Test