fix: replay preload confirmation idempotently
This commit is contained in:
@@ -24,6 +24,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
|||||||
import com.baiyue.firstleg.repository.LoadingListRepository;
|
import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
@@ -553,17 +554,29 @@ public class LoadingListService {
|
|||||||
return "PL-" + sha256(operation + '|' + planId + '|' + normalized);
|
return "PL-" + sha256(operation + '|' + planId + '|' + normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void requireSameRequest(HistoryRow replay, String requestHash) {
|
private JsonNode requireSameRequest(HistoryRow replay, String requestHash) {
|
||||||
String snapshot = replay.getAfterSnapshot();
|
String rawSnapshot = replay.getAfterSnapshot();
|
||||||
if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) {
|
if (!StringUtils.hasText(rawSnapshot)) {
|
||||||
throw conflict("Idempotency-Key 已用于不同请求");
|
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) {
|
private PreloadConfirmationResponse confirmationReplay(HistoryRow replay, String requestHash) {
|
||||||
requireSameRequest(replay, requestHash);
|
JsonNode snapshot = requireSameRequest(replay, requestHash);
|
||||||
try {
|
try {
|
||||||
var response = objectMapper.readTree(replay.getAfterSnapshot()).get("response");
|
JsonNode response = snapshot.get("response");
|
||||||
if (response == null) throw new IllegalStateException("预装确认幂等响应缺失");
|
if (response == null) throw new IllegalStateException("预装确认幂等响应缺失");
|
||||||
return objectMapper.treeToValue(response, PreloadConfirmationResponse.class);
|
return objectMapper.treeToValue(response, PreloadConfirmationResponse.class);
|
||||||
} catch (JsonProcessingException exception) {
|
} catch (JsonProcessingException exception) {
|
||||||
|
|||||||
@@ -541,8 +541,9 @@ class LoadingListServiceTest {
|
|||||||
10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L));
|
10L, 30L, "CONFIRMED", 4, readiness("CONFIRMED", false, true, 80L));
|
||||||
String requestHash = sha256("CONFIRM_PRELOAD|10|3");
|
String requestHash = sha256("CONFIRM_PRELOAD|10|3");
|
||||||
HistoryRow replay = new HistoryRow();
|
HistoryRow replay = new HistoryRow();
|
||||||
replay.setAfterSnapshot(new ObjectMapper().writeValueAsString(
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
java.util.Map.of("requestHash", requestHash, "response", expected)));
|
replay.setAfterSnapshot(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(
|
||||||
|
java.util.Map.of("response", expected, "requestHash", requestHash)));
|
||||||
when(repository.history(anyString())).thenReturn(replay);
|
when(repository.history(anyString())).thenReturn(replay);
|
||||||
|
|
||||||
PreloadConfirmationResponse actual = service.confirmPreload(
|
PreloadConfirmationResponse actual = service.confirmPreload(
|
||||||
@@ -552,6 +553,53 @@ class LoadingListServiceTest {
|
|||||||
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
|
verify(loadingPickService, never()).requirePreloadConfirmationReady(anyLong());
|
||||||
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
|
verify(repository, never()).touchForPreloadConfirmation(anyLong(),
|
||||||
org.mockito.ArgumentMatchers.anyInt(), anyLong(), anyString());
|
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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user