二期内容提交

This commit is contained in:
2026-08-04 12:23:46 +08:00
parent 25d2a13581
commit addde4375e
28 changed files with 897 additions and 132 deletions
@@ -3,7 +3,8 @@ package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
@@ -13,12 +14,14 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("第三方测量开放接口契约")
class OpenApiInboundMeasurementControllerContractTest {
@Test
@DisplayName("回调以原始字符串接收报文,保证签名正文不被重新序列化")
void shouldReceiveRawRequestBody() throws Exception {
@DisplayName("回调接收原始data和唯一图片,保证签名与一对一绑定")
void shouldReceiveRawDataAndImage() throws Exception {
Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receive",
String.class, String.class, String.class, String.class, String.class, String.class);
assertThat(method.getParameters()[5].getAnnotation(RequestBody.class)).isNotNull();
String.class, String.class, String.class, String.class, String.class,
String.class, MultipartFile.class);
assertThat(method.getParameters()[5].getAnnotation(RequestPart.class).value()).isEqualTo("data");
assertThat(method.getParameters()[5].getType()).isEqualTo(String.class);
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class).value()).isEqualTo("image");
}
@Test
@@ -33,6 +36,8 @@ class OpenApiInboundMeasurementControllerContractTest {
.contains("Idempotency-Key")
.contains("appKey + eventNo")
.contains("Nonce")
.contains("inboundDetailId")
.contains("multipart/form-data")
.contains("不改变入库单状态");
}
}
@@ -1,7 +1,11 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository;
@@ -15,12 +19,15 @@ import org.junit.jupiter.api.Test;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.mock.web.MockMultipartFile;
import java.time.OffsetDateTime;
import java.math.BigDecimal;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
@@ -30,15 +37,19 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doThrow;
import org.mockito.ArgumentCaptor;
@DisplayName("第三方测量开放接口服务")
class InboundMeasurementOpenApiServiceTest {
private static final String BODY = """
{"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001",
"deviceCode":"DEVICE-01","measuredAt":"2026-08-01T10:30:00+08:00",
"items":[{"itemNo":"001","lengthCm":50.20,"widthCm":40.10,"heightCm":30.00,
"quantity":5,"boxSpecification":"50.2x40.1x30cm"}]}
"externalDetailNo":"BOX-001","inboundDetailId":"80001","deviceCode":"DEVICE-01",
"measuredAt":"2026-08-01T10:30:00+08:00","lengthCm":50.20,"widthCm":40.10,
"heightCm":30.00,"packageCount":5,"unitWeightKg":12.5,
"boxSpecification":"50.2x40.1x30cm"}
""";
private static final byte[] IMAGE = "image-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
@Test
@DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口")
@@ -47,12 +58,23 @@ class InboundMeasurementOpenApiServiceTest {
InboundOrderLookupEntity inbound = inbound();
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound);
EventResponse response = fixture.service.receive(headers("nonce-1"), BODY);
EventResponse response = fixture.service.receive(headers("nonce-1"), BODY, image());
assertThat(response.accepted()).isTrue();
assertThat(response.status()).isEqualTo("RECEIVED");
verify(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class));
verify(fixture.repository).insertItem(any());
ArgumentCaptor<InboundMeasurementItemEntity> itemCaptor =
ArgumentCaptor.forClass(InboundMeasurementItemEntity.class);
verify(fixture.repository).insertItem(itemCaptor.capture());
assertThat(itemCaptor.getValue().getInboundDetailId()).isEqualTo(80001L);
assertThat(itemCaptor.getValue().getExternalDetailNo()).isEqualTo("BOX-001");
assertThat(itemCaptor.getValue().getSourceImageFileId()).isEqualTo(99001L);
assertThat(itemCaptor.getValue().getItemNo()).isEqualTo("1");
assertThat(itemCaptor.getValue().getTotalVolumeCbm()).isEqualByComparingTo("0.3020");
verify(fixture.orderCargo).detail("internal-token", 10001L, 60001L, 80001L);
verify(fixture.images).upload(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.eq(90001L), org.mockito.ArgumentMatchers.eq(10001L),
org.mockito.ArgumentMatchers.eq(70001L), any());
assertThatNoException().isThrownBy(() ->
InboundMeasurementOpenApiRepository.class.getDeclaredMethod("findInbound", long.class, String.class));
assertThat(InboundMeasurementOpenApiRepository.class.getDeclaredMethods())
@@ -67,38 +89,82 @@ class InboundMeasurementOpenApiServiceTest {
EventResponse first = new EventResponse("M202608010001", "RK202608010001", true,
"RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z"));
ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity();
existing.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(BODY));
existing.setRequestHash(requestHash());
existing.setResultJson(mapper.writeValueAsString(first));
existing.setProcessingStatus("ACCEPTED");
when(fixture.logs.claimIdempotency(1L)).thenReturn(false);
when(fixture.logs.findIdempotencyOwner(10001L, "device-provider", "idem-1")).thenReturn(existing);
EventResponse duplicate = fixture.service.receive(headers("nonce-2"), BODY);
EventResponse duplicate = fixture.service.receive(headers("nonce-2"), BODY, image());
assertThat(duplicate).isEqualTo(first);
verify(fixture.repository, never()).insertMeasurement(any());
verify(fixture.repository, never()).insertItem(any());
}
@Test
@DisplayName("同一订单明细和第三方明细号已经绑定时在上传图片前拒绝")
void shouldRejectAlreadyBoundExternalDetailBeforeUploadingImage() {
Fixture fixture = fixture();
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound());
when(fixture.repository.findExternalDetail(10001L, 80001L, "BOX-001"))
.thenReturn(new InboundMeasurementItemEntity());
assertThatThrownBy(() -> fixture.service.receive(headers("nonce-3"), BODY, image()))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("已绑定");
verify(fixture.images, never()).upload(anyString(),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong(), any());
verify(fixture.repository, never()).insertMeasurement(any());
}
@Test
@DisplayName("图片已保存但测量落库失败时调用文件中心补偿清理")
void shouldCompensateImageWhenMeasurementPersistenceFails() {
Fixture fixture = fixture();
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound());
doThrow(new IllegalStateException("db failed"))
.when(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class));
assertThatThrownBy(() -> fixture.service.receive(headers("nonce-4"), BODY, image()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("db failed");
verify(fixture.images).delete("internal-token", 90001L, 10001L, 70001L, 99001L);
verify(fixture.repository, never()).insertItem(any());
}
private Fixture fixture() {
InboundMeasurementOpenApiRepository repository = mock(InboundMeasurementOpenApiRepository.class);
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
ThirdPartyCallLogService logs = mock(ThirdPartyCallLogService.class);
TransactionTemplate transactions = mock(TransactionTemplate.class);
when(logs.start(anyString(), anyString(), any(), anyString())).thenReturn(1L);
OrderCargoClient orderCargo = mock(OrderCargoClient.class);
MeasurementImageClient images = mock(MeasurementImageClient.class);
when(logs.start(anyString(), anyString(), any(), anyString(), anyString())).thenReturn(1L);
when(logs.claimIdempotency(1L)).thenReturn(true);
when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean()))
.thenReturn(new AuthenticatedMeasurementApp("device-provider", 10001L, 90001L,
Map.of("CN-YW-01", 30001L)));
when(orderCargo.detail("internal-token", 10001L, 60001L, 80001L)).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(80001L, 60001L, 1, "测试货物", 5,
new BigDecimal("62.500"), new BigDecimal("0.3010"))));
when(images.upload(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.eq(90001L), org.mockito.ArgumentMatchers.eq(10001L),
org.mockito.ArgumentMatchers.eq(70001L), any())).thenReturn(ApiResponse.success(
new MeasurementImageClient.UploadResult(99001L, "measure.jpg", "image/jpeg", 13L,
"NOT_REQUIRED", null, null, null, null)));
when(transactions.execute(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
TransactionCallback<EventResponse> callback = invocation.getArgument(0);
return callback.doInTransaction(mock(TransactionStatus.class));
});
InboundMeasurementOpenApiService service = new InboundMeasurementOpenApiService(repository,
authenticator, logs, objectMapper(),
authenticator, logs, orderCargo, images, "internal-token", objectMapper(),
Validation.buildDefaultValidatorFactory().getValidator(), transactions);
return new Fixture(repository, logs, service);
return new Fixture(repository, logs, orderCargo, images, service);
}
private ObjectMapper objectMapper() {
@@ -110,6 +176,7 @@ class InboundMeasurementOpenApiServiceTest {
inbound.setId(70001L);
inbound.setTenantId(10001L);
inbound.setInboundCode("RK202608010001");
inbound.setOrderId(60001L);
inbound.setWarehouseId(30001L);
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setDeleted(0);
@@ -120,8 +187,20 @@ class InboundMeasurementOpenApiServiceTest {
return new MeasurementRequestHeaders("device-provider", "1785551400000", nonce, "signature", "idem-1");
}
private MockMultipartFile image() {
return new MockMultipartFile("image", "measure.jpg", "image/jpeg", IMAGE);
}
private String requestHash() throws Exception {
String imageHash = java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256").digest(IMAGE));
return MeasurementRequestAuthenticator.sha256Hex(BODY + "\n" + imageHash);
}
private record Fixture(InboundMeasurementOpenApiRepository repository,
ThirdPartyCallLogService logs,
OrderCargoClient orderCargo,
MeasurementImageClient images,
InboundMeasurementOpenApiService service) {
}
}