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);
}
}
}
}