test(return): cover isolation and concurrency; fix receipt and packing races

This commit is contained in:
2026-09-15 17:05:07 +08:00
parent 28fa2a001d
commit 6b0de57a4b
18 changed files with 635 additions and 40 deletions
@@ -18,5 +18,7 @@ public final class PdaReturnReceiptRequests {
@Size(max = 128) String cartonMark,
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Item(@NotBlank @Size(max = 128) String skuCode, @Positive long receivedQuantity) { }
public record Item(@NotBlank @Size(max = 128) String skuCode,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
@Positive long receivedQuantity) { }
}
@@ -41,5 +41,7 @@ public final class ReturnInventoryRequests {
@NotNull @Positive BigDecimal weight,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record ValidItem(@NotBlank @Size(max = 128) String skuCode, @Positive long quantity) { }
public record ValidItem(@NotBlank @Size(max = 128) String skuCode,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
@Positive long quantity) { }
}
@@ -22,6 +22,7 @@ public final class ReturnOrderRequests {
@NotEmpty @Size(max = 500) List<@Valid Item> items) { }
public record Item(@NotBlank @Size(max = 128) String skuCode,
@Size(max = 255) String productNameZh,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
@Positive long expectedQuantity) { }
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String returnNo,
String trackingNo, String skuCode, String status) {
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.returnreceipt.dto;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import java.io.IOException;
/** Return quantities are whole units; never truncate fractional JSON input. */
public final class ReturnQuantityDeserializer extends JsonDeserializer<Long> {
@Override
public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException {
if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
return context.reportInputMismatch(Long.class, "退货数量必须为整数");
}
return parser.getLongValue();
}
@Override
public Long getNullValue(DeserializationContext context) throws JsonMappingException {
return context.reportInputMismatch(Long.class, "退货数量不能为空");
}
}
@@ -12,4 +12,6 @@ import java.util.List;
public interface ReturnInventoryCartonItemMapper extends BaseMapper<ReturnInventoryCartonItem> {
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code")
List<ReturnInventoryCartonItem> listCarton(@Param("cartonId") long cartonId);
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code FOR UPDATE")
List<ReturnInventoryCartonItem> lockCarton(@Param("cartonId") long cartonId);
}
@@ -12,6 +12,8 @@ import java.util.List;
public interface ReturnReceiptMapper extends BaseMapper<ReturnReceipt> {
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
ReturnReceipt findKey(@Param("operatorId") String operatorId, @Param("key") String key);
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE")
ReturnReceipt lockKey(@Param("operatorId") String operatorId, @Param("key") String key);
@Select("SELECT * FROM ll_return_receipt WHERE return_order_id=#{orderId} ORDER BY signed_at DESC,id DESC")
List<ReturnReceipt> listOrder(@Param("orderId") Long orderId);
}
@@ -237,11 +237,19 @@ public class ReturnInventoryService {
if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱");
List<ReturnInventoryCarton> sources = new ArrayList<>();
Map<Long, ReturnOrder> sourceOwners = new LinkedHashMap<>();
Map<String, ReturnOrder> candidateOwners = new java.util.TreeMap<>();
for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
if (carton != null && AVAILABLE.equals(carton.getStatus())) {
ReturnInventoryCarton locked = cartons.lockMark(carton.getCartonMark());
sources.add(locked); sourceOwners.put(locked.getId(), order);
candidateOwners.put(carton.getCartonMark(), order);
}
}
// All requests acquire source locks in the same order. The earlier
// candidate query is only a snapshot: availability must be checked again.
for (Map.Entry<String, ReturnOrder> candidate : candidateOwners.entrySet()) {
ReturnInventoryCarton locked = cartons.lockMark(candidate.getKey());
if (locked != null && AVAILABLE.equals(locked.getStatus())) {
sources.add(locked); sourceOwners.put(locked.getId(), candidate.getValue());
}
}
if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱");
@@ -258,7 +266,8 @@ public class ReturnInventoryService {
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
long total = 0;
for (ReturnInventoryCarton source : sources) {
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
// Read committed quantities under the source lock, never a stale MVCC snapshot.
List<ReturnInventoryCartonItem> sourceItems = cartonItems.lockCarton(source.getId());
long sourceTotal = 0; int sourceTypes = 0;
for (ReturnInventoryCartonItem item : sourceItems) {
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
@@ -268,6 +268,13 @@ public class ReturnReceiptService {
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
throw notFound("当前绑定仓没有该退货单");
}
// Waiting for the order lock may allow the same request to commit. Use a
// current read, not the repeatable-read snapshot established by findKey.
ReturnReceipt concurrentReplay = receipts.lockKey(identity.operatorKey(), key);
if (concurrentReplay != null) {
if (!requestHash.equals(concurrentReplay.getRequestHash())) throw conflict("幂等键已用于不同的签收请求");
return confirmed(order, concurrentReplay, COMPLETED.equals(order.getStatus()));
}
if (!Set.of(PENDING_RECEIPT, PARTIALLY_RECEIVED).contains(order.getStatus())) {
throw conflict("仅待签收或签收中的退货单允许 PDA 签收");
}
@@ -287,7 +294,7 @@ public class ReturnReceiptService {
receipt.setSignedAt(now); receipt.setCreatedAt(now);
try { receipts.insert(receipt); }
catch (DuplicateKeyException concurrent) {
ReturnReceipt existing = receipts.findKey(identity.operatorKey(), key);
ReturnReceipt existing = receipts.lockKey(identity.operatorKey(), key);
if (existing != null) return replayReceipt(existing, requestHash);
throw concurrent;
}