fix(firstleg): cancel coload groups by inbound code

This commit is contained in:
2026-08-31 14:53:44 +08:00
parent 314ce09b5d
commit ea615b864a
7 changed files with 142 additions and 34 deletions
@@ -3,7 +3,7 @@ package com.baiyue.firstleg.loading.coload;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.coload.controller.PlatformColoadGroupController;
import com.baiyue.firstleg.loading.coload.controller.InternalColoadGroupController;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelRequest;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CancelByInboundCodeRequest;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.CreateRequest;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.InternalBatchQueryRequest;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
@@ -25,8 +25,8 @@ class ColoadGroupContractTest {
assertThat(base.value()).containsExactly("/api/platform/v1/first-leg/coload-groups");
assertEndpoint("create", "", AuthenticatedUser.class, String.class, CreateRequest.class);
assertEndpoint("cancel", "/{groupId}/cancel", AuthenticatedUser.class, long.class,
String.class, CancelRequest.class);
assertEndpoint("cancel", "/by-inbound-code/{inboundCode}/cancel", AuthenticatedUser.class,
String.class, String.class, CancelByInboundCodeRequest.class);
Method read = PlatformColoadGroupController.class.getDeclaredMethod(
"byInboundCode", AuthenticatedUser.class, String.class);
assertThat(read.getAnnotation(GetMapping.class).value())
@@ -25,7 +25,7 @@ class ColoadGroupOpenApiContractTest {
assertThat(paths).containsKeys(
"/api/platform/v1/first-leg/coload-groups",
"/api/platform/v1/first-leg/coload-groups/{groupId}/cancel",
"/api/platform/v1/first-leg/coload-groups/by-inbound-code/{inboundCode}/cancel",
"/api/platform/v1/first-leg/coload-groups/by-inbound-code/{inboundCode}");
assertThat(paths).doesNotContainKey("/api/platform/v1/first-leg/coload-groups/batch-query");
assertThat(source)
@@ -35,7 +35,6 @@ class ColoadGroupOpenApiContractTest {
.contains("pattern: '^[1-9][0-9]*$'")
.contains("enum: [ACTIVE, LOCKED, CONSUMED, CANCELLED]")
.contains("Idempotency-Key")
.contains("expectedVersion")
.contains("fullPackageCount")
.contains("platform:firstleg:smart-loading:assign")
.contains("platform:firstleg:smart-loading:list")
@@ -45,6 +44,7 @@ class ColoadGroupOpenApiContractTest {
.doesNotContain("destinationType")
.doesNotContain("destinationWarehouseId")
.doesNotContain("destinationCode")
.doesNotContain("expectedVersion")
.doesNotContain("platform:firstleg:coload");
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
assertThat(memberProperties).containsOnlyKeys("inboundCode");
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.loading.coload;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.CommandRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.MemberRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundGroupLookupRow;
@@ -25,6 +26,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -192,11 +194,13 @@ class ColoadGroupServiceTest {
@Test
void lockedGroupCannotBeCancelled() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "LOCKED")));
when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), eq("cancel-key"), anyString()))
.thenReturn(1);
when(mapper.lockGroup(500L)).thenReturn(group(500L, "LOCKED", 700L, 2));
assertThatThrownBy(() -> service.cancel(500L, 2, "change", "cancel-key", 7L, "operator"))
assertThatThrownBy(() -> service.cancelByInboundCode(
"IN-A", "change", "cancel-key", 7L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("未锁定");
verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any());
@@ -204,6 +208,7 @@ class ColoadGroupServiceTest {
@Test
void activeGroupCancelsAsAWholeAndReleasesEveryMembership() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE")));
when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), eq("cancel-all"), anyString()))
.thenReturn(1);
when(mapper.lockGroup(500L)).thenReturn(group(500L, "ACTIVE", null, 2));
@@ -217,10 +222,73 @@ class ColoadGroupServiceTest {
when(mapper.members(500L)).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50)));
var result = service.cancel(500L, 2, "rebuild", "cancel-all", 7L, "operator");
var result = service.cancelByInboundCode(
"IN-A", "rebuild", "cancel-all", 7L, "operator");
assertThat(result.status()).isEqualTo("CANCELLED");
verify(mapper).closeMembers(500L);
verify(mapper).insertHistory(anyLong(), eq(500L), eq("CANCEL"), eq("ACTIVE"),
eq("CANCELLED"), isNull(), eq("cancel-all"), eq("rebuild"), eq(7L), eq("operator"));
}
@Test
void cancelReplayReturnsOriginalResultWithoutResolvingOrMutatingCurrentGroup() {
CommandRow command = command("SUCCESS", 500L);
when(mapper.command(7L, "CANCEL", "cancel-replay")).thenReturn(command);
when(mapper.group(500L)).thenReturn(group(500L, "CANCELLED", null, 3));
when(mapper.members(500L)).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50)));
var result = service.cancelByInboundCode(
" IN-A ", "rebuild", "cancel-replay", 7L, "operator");
assertThat(result.status()).isEqualTo("CANCELLED");
verify(mapper, never()).groupsByInboundCode(anyString());
verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any());
verify(mapper, never()).insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(),
any(), anyString(), any(), anyLong(), any());
}
@Test
void cancelReplayRejectsDifferentPayloadAndConcurrentProcessing() {
CommandRow differentPayload = command("SUCCESS", 500L);
differentPayload.setRequestHash("different-hash");
when(mapper.command(7L, "CANCEL", "same-key")).thenReturn(differentPayload);
when(mapper.command(7L, "CANCEL", "processing-key"))
.thenReturn(command("PROCESSING", null));
assertThatThrownBy(() -> service.cancelByInboundCode(
"IN-A", "changed", "same-key", 7L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
assertThatThrownBy(() -> service.cancelByInboundCode(
"IN-A", "rebuild", "processing-key", 7L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("正在处理中");
verify(mapper, never()).cancel(anyLong(), anyInt(), anyLong(), any());
}
@Test
void concurrentStateChangeOrPartialMemberCloseRollsBackWithoutAuditCompletion() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE")));
when(mapper.insertCommand(anyLong(), eq(7L), eq("CANCEL"), anyString(), anyString()))
.thenReturn(1);
when(mapper.lockGroup(500L))
.thenReturn(group(500L, "CANCELLED", null, 3), group(500L, "ACTIVE", null, 2));
assertThatThrownBy(() -> service.cancelByInboundCode(
"IN-A", "rebuild", "lost-race", 7L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("未锁定");
when(mapper.members(500L)).thenReturn(List.of(
member(101L, 11L, 1001L, 30), member(202L, 12L, 1002L, 50)));
when(mapper.cancel(500L, 2, 7L, "operator")).thenReturn(1);
when(mapper.closeMembers(500L)).thenReturn(1);
assertThatThrownBy(() -> service.cancelByInboundCode(
"IN-A", "rebuild", "partial-close", 7L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("成员状态已变化");
verify(mapper, never()).insertHistory(anyLong(), anyLong(), eq("CANCEL"), any(), anyString(),
any(), anyString(), any(), anyLong(), any());
verify(mapper, never()).completeCommand(anyLong(), anyLong());
}
@Test
@@ -237,6 +305,8 @@ class ColoadGroupServiceTest {
InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); second.setActiveGroupId(500L);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, fragment, second));
when(mapper.lockToPlan(500L, 700L, 7L, "operator")).thenReturn(1);
when(mapper.insertHistory(anyLong(), eq(500L), eq("LOCK"), eq("ACTIVE"), eq("LOCKED"),
eq(700L), eq("assign-whole"), any(), eq(7L), eq("operator"))).thenReturn(1);
when(mapper.completeCommand(anyLong(), eq(500L))).thenReturn(1);
when(mapper.group(500L)).thenReturn(group(500L, "LOCKED", 700L, 1));
@@ -318,6 +388,24 @@ class ColoadGroupServiceTest {
return value;
}
private static CommandRow command(String status, Long resultGroupId) {
CommandRow value = new CommandRow();
value.setId(900L);
value.setRequestHash(sha256("CANCEL_BY_INBOUND_CODE|IN-A|rebuild"));
value.setCommandStatus(status);
value.setResultGroupId(resultGroupId);
return value;
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static org.assertj.core.groups.Tuple tuple(Object... values) {
return org.assertj.core.groups.Tuple.tuple(values);
}