From f946285bccb64dae0ed06b03af9451880617700c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 04:25:47 +0800 Subject: [PATCH] fix: keep fleet booking timestamps stable across persistent replay --- .../fleet/trip/FleetBookingService.java | 14 ++++- .../trip/FleetBookingServiceReplayTest.java | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingServiceReplayTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index 82e53d986..73e8df782 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -20,6 +20,8 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.time.Clock; +import java.time.temporal.ChronoUnit; import java.util.Currency; import java.util.HexFormat; import java.util.Objects; @@ -38,15 +40,22 @@ public class FleetBookingService { private final FleetBookingIncomingGateway incoming; private final FleetBookingQuoteContext quoteContext; private final FleetQuoteCandidateService quotes; + private final Clock clock; @jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders; @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; @jakarta.annotation.Resource private FleetDispatchGateway dispatches; @jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands; + @org.springframework.beans.factory.annotation.Autowired public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){ + this(trips,bookings,reservations,history,execution,customers,json,incoming,quoteContext,quotes,Clock.systemUTC()); + } + FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, + FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, + FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes,Clock clock){ this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming; - this.quoteContext=quoteContext;this.quotes=quotes; + this.quoteContext=quoteContext;this.quotes=quotes;this.clock=clock; } public java.util.Map incomingStatuses(java.util.List rows) { return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList()); @@ -305,7 +314,8 @@ public class FleetBookingService { private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}} /** 请求对象排除重放元数据;排车来源键的数组必须保留全部元素及顺序。 */ private String hash(Object value){try{com.fasterxml.jackson.databind.JsonNode node=json.valueToTree(value);if(node instanceof ObjectNode object){object.remove("idempotencyKey");object.remove("tripRecordVersion");}return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}} - private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);} + /** DATETIME(3)持久化前固定毫秒精度,首次回执与重新读取后的幂等重放保持同一时间事实。 */ + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS);} private static String trim(String value){return value==null||value.isBlank()?null:value.trim();} private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);} private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingServiceReplayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingServiceReplayTest.java new file mode 100644 index 000000000..76d56579a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingServiceReplayTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 模拟重新从DATETIME(3)读取的实体,不让同一个内存对象掩盖首次与重放精度差异。 */ +class FleetBookingServiceReplayTest { + @Test void incomingReplayKeepsPersistedMillisecondTimestampAndCreatesOnlyOneIncoming() throws Exception { + var json=new ObjectMapper().findAndRegisterModules(); + var trips=mock(FleetTripMapper.class);var bookings=mock(FleetBookingMapper.class); + var history=mock(FleetTripService.class);var incoming=mock(FleetBookingIncomingGateway.class); + var trip=new FleetTripEntity();trip.setId(2L);trip.setTripNo("CT-ONE");trip.setStatus("PLANNED");trip.setWarehouseId(42L); + trip.setWarehouseName("TEST");trip.setRecordVersion(5L);trip.setPlannedDepartureAt(LocalDateTime.parse("2026-10-23T12:00:00")); + when(trips.lockByNo("CT-ONE")).thenReturn(trip); + var row=new FleetBookingEntity();row.setId(1L);row.setTripId(2L);row.setTripNo("CT-ONE");row.setBookingNo("FB-ONE"); + row.setCustomerId("101");row.setStatus("CONFIRMED");row.setRecordVersion(7L);row.setDestinationKey("FBA:ONT8"); + row.setUpdatedAt(LocalDateTime.parse("2026-10-23T09:00:00")); + row.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8","TEST",1,1,BigDecimal.ONE,BigDecimal.ONE, + "PALLET",BigDecimal.TEN,"USD",null,null,null))); + var persisted=new AtomicReference<>(row); + when(bookings.lock(2L,"FB-ONE")).thenAnswer(call->json.convertValue(persisted.get(),FleetBookingEntity.class)); + when(bookings.updateById(any(FleetBookingEntity.class))).thenAnswer(call->{ + var stored=json.convertValue(call.getArgument(0),FleetBookingEntity.class); + stored.setUpdatedAt(stored.getUpdatedAt().truncatedTo(ChronoUnit.MILLIS));persisted.set(stored);return 1; + }); + doAnswer(call->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}) + .when(history).recordExecution(eq(trip),eq(5L),eq("BOOKING_INCOMING"),anyString(),eq("operator")); + when(incoming.create(eq("operator"),any())).thenReturn(new FleetBookingIncomingGateway.Evidence( + "ZY-ONE","RK-ONE","42","FBA:ONT8","WAIT_PDA_RECEIVING",null,List.of(),0)); + var service=new FleetBookingService(trips,bookings,new FleetBookingReservations(bookings,json),history, + mock(FleetTripExecutionService.class),mock(FleetCustomerDirectory.class),json,incoming, + mock(FleetBookingQuoteContext.class),mock(FleetQuoteCandidateService.class), + Clock.fixed(Instant.parse("2026-10-23T10:00:00.123456789Z"),ZoneOffset.UTC)); + var command=new FleetBookingModels.StartIncoming(5L,7L,Instant.parse("2026-10-23T11:00:00Z"),"TEST",null,null,null,null,"test-incoming-key"); + var first=service.startIncoming("CT-ONE","FB-ONE","operator",command); + var replay=service.startIncoming("CT-ONE","FB-ONE","operator",command); + assertFalse(first.replayed());assertTrue(replay.replayed()); + assertEquals(Instant.parse("2026-10-23T10:00:00.123Z"),first.booking().updatedAt()); + assertEquals(first.booking(),replay.booking());assertEquals(first.tripRecordVersion(),replay.tripRecordVersion()); + verify(incoming,times(1)).create(eq("operator"),any());verify(bookings,times(1)).updateById(any(FleetBookingEntity.class)); + } +}