fix(return): replay concurrent merges and extend integrity acceptance

This commit is contained in:
2026-09-15 18:00:08 +08:00
parent 6b0de57a4b
commit 45018987ef
8 changed files with 246 additions and 11 deletions
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.returnreceipt.service;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryOperationMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -14,5 +16,11 @@ class ReturnCurrentReadContractTest {
assertTrue(receiptSql.endsWith("FOR UPDATE"));
assertTrue(itemSql.endsWith("FOR UPDATE"));
assertTrue(itemSql.contains("ORDER BY sku_code"));
String operationSql = String.join(" ", ReturnInventoryOperationMapper.class.getMethod("lockKey", String.class, String.class).getAnnotation(Select.class).value());
String cartonSql = String.join(" ", ReturnInventoryCartonMapper.class.getMethod("lockId", long.class).getAnnotation(Select.class).value());
assertTrue(operationSql.contains("operator_id=#{operatorId} AND idempotency_key=#{key}"));
assertTrue(operationSql.endsWith("FOR UPDATE"));
assertTrue(cartonSql.contains("WHERE id=#{id}"));
assertTrue(cartonSql.endsWith("FOR UPDATE"));
}
}
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceipt;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
@@ -22,6 +23,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.dao.DuplicateKeyException;
import java.math.BigDecimal;
import java.util.List;
@@ -32,6 +34,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.springframework.web.server.ResponseStatusException;
@@ -195,6 +198,61 @@ class ReturnInventoryServiceTest {
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 0L);
}
@Test
void sameKeyInsertRaceReplaysCommittedResultAndLabelUsingOnlyCurrentReads() {
var request = mergeRequest(1);
var winner = winningOperation(request);
when(operations.insert(any(ReturnInventoryOperation.class))).thenThrow(new DuplicateKeyException("same key"));
when(operations.lockKey("operator-2", "concurrent-key")).thenReturn(winner);
ReturnInventoryCarton target = new ReturnInventoryCarton();
target.setId(31L); target.setCartonMark("RC-WINNER"); target.setTotalQuantity(1L); target.setSkuTypes(1);
when(cartons.lockId(31L)).thenReturn(target);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 1L)));
var result = service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), request);
assertThat(result.cartonMark()).isEqualTo("RC-WINNER");
assertThat(result.totalQuantity()).isEqualTo(1L);
assertThat(result.label().items()).hasSize(1);
assertThat(result.label().items().get(0).quantity()).isEqualTo(1L);
assertThat(result.label().printableHtml()).contains("RC-WINNER", "SKU-A");
verify(operations).findKey("operator-2", "concurrent-key");
verify(operations).lockKey("operator-2", "concurrent-key");
verify(cartons, never()).selectById(anyLong());
verify(cartonItems, never()).listCarton(anyLong());
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
verify(cartonItems, never()).insert(any(ReturnInventoryCartonItem.class));
verify(operations, never()).updateById(any(ReturnInventoryOperation.class));
verifyNoInteractions(orders, receipts, balances, numbers);
}
@Test
void sameKeyInsertRaceWithDifferentPayloadIsConflictWithoutInventoryWrites() {
when(operations.insert(any(ReturnInventoryOperation.class))).thenThrow(new DuplicateKeyException("same key"));
when(operations.lockKey("operator-2", "concurrent-key")).thenReturn(winningOperation(mergeRequest(1)));
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2)));
assertThat(error.getStatusCode().value()).isEqualTo(409);
verifyNoInteractions(cartons, cartonItems, orders, receipts, balances, numbers);
}
@Test
void unrelatedDuplicateInsertFailureIsNotMistakenForSuccessfulReplay() {
var duplicate = new DuplicateKeyException("different unique constraint");
when(operations.insert(any(ReturnInventoryOperation.class))).thenThrow(duplicate);
assertThat(assertThrows(DuplicateKeyException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(1)))).isSameAs(duplicate);
verify(operations).lockKey("operator-2", "concurrent-key");
verifyNoInteractions(cartons, cartonItems, orders, receipts, balances, numbers);
}
private ReturnInventoryOperation winningOperation(ReturnInventoryRequests.Merge request) {
ReturnInventoryOperation operation = new ReturnInventoryOperation();
operation.setResultCartonId(31L);
operation.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request));
return operation;
}
private ReturnInventoryCarton prepareConcurrentSource() {
ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L);
ReturnInventoryCarton snapshot = new ReturnInventoryCarton();