This commit is contained in:
2026-08-08 10:31:44 +08:00
parent ced35f1600
commit 2644718b5f
32 changed files with 9710 additions and 31 deletions
@@ -4,8 +4,10 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.baiyue.firstleg.service.InboundMeasurementOpenApiService;
import com.baiyue.firstleg.service.MojiaMeasurementOpenApiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
@@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.multipart.MultipartFile;
/** 第三方设备长宽高测量开放接口。 */
@@ -36,6 +39,21 @@ import org.springframework.web.multipart.MultipartFile;
public class OpenApiInboundMeasurementController {
@Resource
private InboundMeasurementOpenApiService service;
@Resource
private MojiaMeasurementOpenApiService mojiaService;
@Operation(summary = "墨家设备按箱码上传单箱测量结果",
description = "首轮联调使用固定 X-App-Key;JSON 中 picture 为 JPEG Data URL,boxCode 原样取自柏悦箱唛二维码")
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<CartonMeasurementResult> receiveMojia(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader(value = "X-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-Nonce", required = false) String nonce,
@RequestHeader(value = "X-Signature", required = false) String signature,
@RequestBody String rawJson) {
return ApiResponse.success(mojiaService.receive(
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, null), rawJson));
}
@Operation(summary = "提交一条第三方箱规测量结果和图片",
description = "multipart 包含 data 原始JSON和 image 图片;联调关闭签名时只需固定 X-App-Key 和 Idempotency-Key,恢复后按六行 HMAC 规范签名")
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 墨家设备按箱码上传的 JSON 协议。 */
public final class MojiaMeasurementDtos {
private MojiaMeasurementDtos() {
}
public record UploadRequest(
@NotBlank @Size(max = 128) String boxCode,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal length,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal width,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal height,
@DecimalMin("0.001") @DecimalMax("999999999999999.999") BigDecimal weight,
@NotBlank @Size(max = 64) String machineNo,
LocalDateTime measuredAt,
@NotBlank @Size(max = 7_100_000) String picture
) {
}
}
@@ -0,0 +1,110 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadRequest;
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Set;
/** 将墨家固定 AppKey + JSON/Base64 协议转换为统一单箱测量命令。 */
@Service
public class MojiaMeasurementOpenApiService {
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
private static final String JPEG_PREFIX = "data:image/jpeg;base64,";
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
private final MeasurementRequestAuthenticator authenticator;
private final CartonMeasurementService cartonMeasurements;
private final ObjectMapper objectMapper;
private final Validator validator;
public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator,
CartonMeasurementService cartonMeasurements,
ObjectMapper objectMapper,
Validator validator) {
this.authenticator = authenticator;
this.cartonMeasurements = cartonMeasurements;
this.objectMapper = objectMapper;
this.validator = validator;
}
public CartonMeasurementResult receive(MeasurementRequestHeaders headers, String rawJson) {
AuthenticatedMeasurementApp app = authenticator.authenticate(
"POST", CALLBACK_PATH, headers, rawJson, false);
UploadRequest request = parseAndValidate(rawJson);
MultipartFile image = decodeJpeg(request.picture());
return cartonMeasurements.upsert(app.tenantId(), app.subjectId(), new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.machineNo(), request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(), image));
}
private UploadRequest parseAndValidate(String rawJson) {
if (rawJson == null || rawJson.isBlank()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "请求 JSON 不能为空");
}
final UploadRequest request;
try {
request = objectMapper.readValue(rawJson, UploadRequest.class);
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "请求 JSON 格式错误");
}
Set<ConstraintViolation<UploadRequest>> violations = validator.validate(request);
if (!violations.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
violations.iterator().next().getPropertyPath() + " 参数不合法");
}
return request;
}
private MultipartFile decodeJpeg(String picture) {
if (!picture.startsWith(JPEG_PREFIX)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"picture 必须使用 data:image/jpeg;base64, 格式");
}
final byte[] bytes;
try {
bytes = Base64.getDecoder().decode(picture.substring(JPEG_PREFIX.length()));
} catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED, "picture Base64 内容无效");
}
if (bytes.length == 0 || bytes.length > MAX_IMAGE_BYTES
|| bytes.length < 3 || (bytes[0] & 0xff) != 0xff || (bytes[1] & 0xff) != 0xd8
|| (bytes[2] & 0xff) != 0xff) {
throw new BizException(ResultCode.VALIDATION_FAILED, "picture 不是有效的 JPEG 图片或超过5MB");
}
return new InMemoryMultipartFile(bytes);
}
private record InMemoryMultipartFile(byte[] content) implements MultipartFile {
@Override public String getName() { return "image"; }
@Override public String getOriginalFilename() { return "mojia-measurement.jpg"; }
@Override public String getContentType() { return "image/jpeg"; }
@Override public boolean isEmpty() { return content.length == 0; }
@Override public long getSize() { return content.length; }
@Override public byte[] getBytes() { return content.clone(); }
@Override public InputStream getInputStream() { return new ByteArrayInputStream(content); }
@Override public void transferTo(File destination) throws IOException {
try (FileOutputStream output = new FileOutputStream(destination)) {
output.write(content);
}
}
}
}
@@ -236,6 +236,19 @@ paths:
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [boxCode, length, width, height, machineNo, picture]
properties:
boxCode: {type: string, maxLength: 128, example: 'RK-2085911321565003778#4-1'}
length: {type: number, format: decimal, minimum: 0.001}
width: {type: number, format: decimal, minimum: 0.001}
height: {type: number, format: decimal, minimum: 0.001}
weight: {type: number, format: decimal, minimum: 0.001, nullable: true}
machineNo: {type: string, maxLength: 64}
measuredAt: {type: string, format: date-time, nullable: true}
picture: {type: string, description: 'data:image/jpeg;base64,...'}
multipart/form-data:
schema:
type: object
@@ -30,6 +30,19 @@ class OpenApiInboundMeasurementControllerContractTest {
assertThat(method.getParameters()[4].getAnnotation(RequestHeader.class).required()).isTrue();
}
@Test
@DisplayName("墨家JSON入口只强制固定AppKey且保留原始JSON")
void shouldExposeMojiaJsonCompatibilityEntry() throws Exception {
Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receiveMojia",
String.class, String.class, String.class, String.class, String.class);
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).required()).isTrue();
assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).required()).isFalse();
assertThat(method.getParameters()[2].getAnnotation(RequestHeader.class).required()).isFalse();
assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).required()).isFalse();
assertThat(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).consumes())
.containsExactly("application/json");
}
@Test
@DisplayName("静态OpenAPI声明回调、状态查询、签名和三层幂等")
void shouldDocumentMeasurementOpenApiContract() throws Exception {
@@ -0,0 +1,55 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MojiaMeasurementOpenApiServiceTest {
@Test
void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception {
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
CartonMeasurementService cartons = mock(CartonMeasurementService.class);
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator());
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of()));
when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult(
1L, 1, 2L, "RK-001#4-1", 1, new BigDecimal("60.1"), new BigDecimal("40.2"),
new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true));
CartonMeasurementResult result = service.receive(headers, json());
assertThat(result.boxCode()).isEqualTo("RK-001#4-1");
ArgumentCaptor<CartonMeasurementCommand> command = ArgumentCaptor.forClass(CartonMeasurementCommand.class);
verify(cartons).upsert(eq(10001L), eq(90001L), command.capture());
assertThat(command.getValue().boxCode()).isEqualTo("RK-001#4-1");
assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1");
assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg");
assertThat(command.getValue().image().getBytes()).containsExactly(0xff, 0xd8, 0xff, 0xd9);
}
private String json() {
return """
{"boxCode":"RK-001#4-1","length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="}
""";
}
}