二期内容提交

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
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
@FeignClient(name = "file-service", contextId = "firstlegMeasurementImageClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/measurement-images")
public interface MeasurementImageClient {
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = "multipart/form-data")
ApiResponse<UploadResult> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file);
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
ApiResponse<Void> delete(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId);
record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
String recognitionStatus, BigDecimal weightValue, String weightUnit,
Double confidence, String error) {
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
public interface OrderCargoClient {
@GetMapping("/{tenantId}/{orderId}/{cargoId}")
ApiResponse<CargoBinding> detail(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId,
@PathVariable long cargoId);
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
}
}
@@ -12,19 +12,21 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
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.multipart.MultipartFile;
/** 第三方设备长宽高测量开放接口。 */
@Validated
@RestController
@RequestMapping("/openapi/v1/inbound-measurements")
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码接收长宽高结果,只落测量数据,不审核、计费或入库")
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码和订单货物明细接收一条箱规及一张图片,只落测量数据,不审核、计费或入库")
@SecurityRequirement(name = "measurementAppKey")
@SecurityRequirement(name = "measurementTimestamp")
@SecurityRequirement(name = "measurementNonce")
@@ -36,25 +38,25 @@ public class OpenApiInboundMeasurementController {
this.service = service;
}
@Operation(summary = "提交第三方长宽高测量结果",
description = "签名正文为 METHOD、PATH、时间戳、Nonce、Idempotency-Key、原始 body SHA-256 六行;只保存调用日志、测量批次和明细")
@Operation(summary = "提交一条第三方箱规测量结果和图片",
description = "multipart 包含 data 原始JSON和 image 图片;签名 body 为 data原文、换行、图片SHA-256,外层仍按 METHOD、PATH、时间戳、Nonce、Idempotency-Key、body SHA-256 六行签名")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功或业务失败,按响应体 code 判断"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "413", description = "请求体超过网关限制"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
})
@PostMapping
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<EventResponse> receive(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
description = "原始 UTF-8 JSON;任何空白差异都会改变签名")
@RequestBody String rawBody) {
@RequestPart("data") String rawData,
@RequestPart("image") MultipartFile image) {
return ApiResponse.success(service.receive(
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey), rawBody));
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey),
rawData, image));
}
@Operation(summary = "查询第三方测量事件状态",
@@ -1,65 +1,71 @@
package com.baiyue.firstleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
/** 第三方长宽高测量开放接口 DTO。 */
public final class OpenApiMeasurementDtos {
private OpenApiMeasurementDtos() {
}
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码提交的第三方长宽高测量结果")
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码和订单货物明细提交一条箱规测量结果")
public record CallbackRequest(
@NotBlank @Size(max = 128)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
String eventNo,
@NotBlank @Size(max = 128)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
@Schema(description = "第三方测量明细唯一号,用于防止同一物理明细重复绑定", example = "BOX-20260801-0001")
String externalDetailNo,
@NotBlank @Size(max = 50)
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
String warehouseCode,
@NotBlank @Size(max = 40)
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
String inboundCode,
@NotNull @Min(1)
@Schema(description = "由柏悦订单明细接口提供的货物明细ID", example = "190000000000000001")
Long inboundDetailId,
@NotBlank @Size(max = 64)
@Schema(description = "测量设备编码", example = "DEVICE-01")
String deviceCode,
@NotNull
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
OffsetDateTime measuredAt,
@NotEmpty @Size(max = 500) List<@Valid Item> items) {
}
@Schema(name = "InboundMeasurementItem", description = "相同箱规的测量明细")
public record Item(
@NotBlank @Size(max = 32)
@Pattern(regexp = "[A-Za-z0-9._:-]+") String itemNo,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal lengthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
@NotNull @Min(1) @Max(1000000) Integer quantity,
@Size(max = 2048) String sourceImageUrl,
@NotBlank @Size(max = 64) String boxSpecification) {
@NotNull @Min(1) @Max(1000000) Integer packageCount,
@DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal unitWeightKg,
@NotBlank @Size(max = 64) String boxSpecification,
@Size(max = 500) String remark) {
}
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
public record EventResponse(
String eventNo,
String inboundCode,
Long inboundDetailId,
Long measurementId,
Long measurementItemId,
Long imageFileId,
boolean accepted,
String status,
OffsetDateTime receivedAt) {
public EventResponse(String eventNo, String inboundCode, boolean accepted,
String status, OffsetDateTime receivedAt) {
this(eventNo, inboundCode, null, null, null, null, accepted, status, receivedAt);
}
}
}
@@ -22,6 +22,7 @@ public class InboundMeasurementEntity {
private String sourceType;
private String sourceCode;
private String externalEventNo;
private String externalRequestHash;
private String deviceCode;
private String warehouseCode;
private LocalDateTime measuredAt;
@@ -18,6 +18,8 @@ public class InboundMeasurementItemEntity {
private Long id;
private Long tenantId;
private Long measurementId;
private Long inboundDetailId;
private String externalDetailNo;
private String itemNo;
private String boxSpecification;
private Integer packageCount;
@@ -14,6 +14,7 @@ public class InboundOrderLookupEntity {
private Long id;
private Long tenantId;
private String inboundCode;
private Long orderId;
private Long warehouseId;
private String processStatus;
private Integer deleted;
@@ -53,4 +53,19 @@ public class InboundMeasurementOpenApiRepository {
items.insert(item);
}
public InboundMeasurementItemEntity findSingleItem(long tenantId, long measurementId) {
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId)
.last("LIMIT 1"));
}
public InboundMeasurementItemEntity findExternalDetail(long tenantId, long inboundDetailId,
String externalDetailNo) {
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getInboundDetailId, inboundDetailId)
.eq(InboundMeasurementItemEntity::getExternalDetailNo, externalDetailNo));
}
}
@@ -1,11 +1,13 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.CallbackRequest;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.Item;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
@@ -15,63 +17,87 @@ 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 feign.FeignException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.multipart.MultipartFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.Set;
/**
* 第三方测量接收编排。只写调用日志、测量批次和明细,不提交审核且不更新入库主单。
* 第三方单条测量接收编排:按入仓码定位主单、按订单货物明细ID绑定来源、
* 将同一请求携带的图片保存到文件中心,再落一条测量批次和一条箱规明细。
* 本服务不提交审核、不计费且不更新入库主单。
*/
@Service
public class InboundMeasurementOpenApiService {
private static final Logger log = LoggerFactory.getLogger(InboundMeasurementOpenApiService.class);
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private static final String CALCULATION_RULE_VERSION = "V1_CEILING_4";
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
private final InboundMeasurementOpenApiRepository repository;
private final MeasurementRequestAuthenticator authenticator;
private final ThirdPartyCallLogService callLogService;
private final OrderCargoClient orderCargoClient;
private final MeasurementImageClient imageClient;
private final String internalToken;
private final ObjectMapper objectMapper;
private final Validator validator;
private final TransactionTemplate transactionTemplate;
public InboundMeasurementOpenApiService(InboundMeasurementOpenApiRepository repository,
MeasurementRequestAuthenticator authenticator,
ThirdPartyCallLogService callLogService,
ObjectMapper objectMapper, Validator validator,
TransactionTemplate transactionTemplate) {
public InboundMeasurementOpenApiService(
InboundMeasurementOpenApiRepository repository,
MeasurementRequestAuthenticator authenticator,
ThirdPartyCallLogService callLogService,
OrderCargoClient orderCargoClient,
MeasurementImageClient imageClient,
@Value("${baiyue.security.internal-service-token}") String internalToken,
ObjectMapper objectMapper,
Validator validator,
TransactionTemplate transactionTemplate) {
this.repository = repository;
this.authenticator = authenticator;
this.callLogService = callLogService;
this.orderCargoClient = orderCargoClient;
this.imageClient = imageClient;
this.internalToken = internalToken;
this.objectMapper = objectMapper;
this.validator = validator;
this.transactionTemplate = transactionTemplate;
}
public EventResponse receive(MeasurementRequestHeaders headers, String rawBody) {
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawBody);
public EventResponse receive(MeasurementRequestHeaders headers, String rawData, MultipartFile image) {
requireImage(image);
String signedPayload = signedPayload(rawData, image);
String requestHash = MeasurementRequestAuthenticator.sha256Hex(signedPayload);
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawData, requestHash);
try {
AuthenticatedMeasurementApp app = authenticator.authenticate(
"POST", CALLBACK_PATH, headers, rawBody, true);
CallbackRequest request = parseAndValidate(rawBody);
String requestHash = MeasurementRequestAuthenticator.sha256Hex(rawBody);
"POST", CALLBACK_PATH, headers, signedPayload, true);
CallbackRequest request = parseAndValidate(rawData);
if (!callLogService.claimIdempotency(logId)) {
EventResponse replay = replayIdempotency(app, headers.idempotencyKey(), requestHash);
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "duplicate", writeJson(replay));
return replay;
}
EventResponse result = transactionTemplate.execute(status ->
persist(app, request, rawBody, requestHash));
String resultJson = writeJson(result);
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", resultJson);
persist(app, request, rawData, requestHash, image));
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
return result;
} catch (RuntimeException exception) {
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
@@ -86,14 +112,11 @@ public class InboundMeasurementOpenApiService {
try {
AuthenticatedMeasurementApp app = authenticator.authenticate("GET", path, headers, "", false);
InboundMeasurementEntity measurement = repository.findEvent(app.tenantId(), app.appKey(), eventNo);
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
}
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), measurement.getInboundId());
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
}
EventResponse result = response(measurement, inbound.getInboundCode());
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
InboundMeasurementItemEntity item = repository.findSingleItem(app.tenantId(), measurement.getId());
EventResponse result = response(measurement, inbound.getInboundCode(), item);
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
return result;
} catch (RuntimeException exception) {
@@ -103,23 +126,20 @@ public class InboundMeasurementOpenApiService {
}
}
private EventResponse persist(AuthenticatedMeasurementApp app,
CallbackRequest request, String rawBody, String requestHash) {
InboundMeasurementEntity existingEvent = repository.findEvent(
app.tenantId(), app.appKey(), request.eventNo());
if (existingEvent != null) {
if (!MeasurementRequestAuthenticator.sha256Hex(existingEvent.getRawPayloadJson()).equals(requestHash)) {
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的报文内容不一致");
private EventResponse persist(AuthenticatedMeasurementApp app, CallbackRequest request,
String rawData, String requestHash, MultipartFile image) {
InboundMeasurementEntity existing = repository.findEvent(app.tenantId(), app.appKey(), request.eventNo());
if (existing != null) {
if (!requestHash.equals(existing.getExternalRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的测量数据或图片不一致");
}
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existingEvent.getInboundId());
EventResponse result = response(existingEvent, inbound == null ? request.inboundCode() : inbound.getInboundCode());
return result;
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existing.getInboundId());
return response(existing, inbound == null ? request.inboundCode() : inbound.getInboundCode(),
repository.findSingleItem(app.tenantId(), existing.getId()));
}
Long boundWarehouseId = app.warehouseBindings().get(request.warehouseCode());
if (boundWarehouseId == null) {
throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
}
if (boundWarehouseId == null) throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), request.inboundCode());
if (inbound == null || !boundWarehouseId.equals(inbound.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "入仓码不存在或不属于绑定仓库");
@@ -128,17 +148,80 @@ public class InboundMeasurementOpenApiService {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许接收测量结果");
}
InboundMeasurementEntity measurement = buildMeasurement(app, request, rawBody, inbound.getId());
repository.insertMeasurement(measurement);
for (Item item : request.items()) {
repository.insertItem(buildItem(app.tenantId(), measurement.getId(), item));
OrderCargoClient.CargoBinding cargo = requireCargo(app.tenantId(), inbound, request.inboundDetailId());
if (repository.findExternalDetail(app.tenantId(), cargo.cargoId(), request.externalDetailNo()) != null) {
throw new BizException(ResultCode.CONFLICT, "该订单明细已绑定相同第三方测量明细号");
}
EventResponse result = response(measurement, request.inboundCode());
return result;
MeasurementImageClient.UploadResult uploaded = uploadImage(app, inbound, image);
InboundMeasurementEntity measurement = buildMeasurement(app, request, rawData, requestHash, inbound.getId());
InboundMeasurementItemEntity item = buildItem(app.tenantId(), measurement.getId(), request, cargo,
uploaded.fileId());
try {
repository.insertMeasurement(measurement);
repository.insertItem(item);
} catch (RuntimeException exception) {
cleanupImage(app, inbound, uploaded.fileId());
throw exception;
}
return response(measurement, request.inboundCode(), item);
}
private void cleanupImage(AuthenticatedMeasurementApp app, InboundOrderLookupEntity inbound, long fileId) {
try {
imageClient.delete(internalToken, app.subjectId(), app.tenantId(), inbound.getId(), fileId);
} catch (RuntimeException cleanupFailure) {
log.warn("第三方测量保存失败后的图片补偿清理失败 tenantId={}, inboundId={}, fileId={}",
app.tenantId(), inbound.getId(), fileId, cleanupFailure);
}
}
private OrderCargoClient.CargoBinding requireCargo(long tenantId, InboundOrderLookupEntity inbound,
long cargoId) {
if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效订单");
}
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(
internalToken, tenantId, inbound.getOrderId(), cargoId);
if (response == null || !response.successful() || response.data() == null) {
throw downstream(response, "订单货物明细校验失败");
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法校验入仓明细");
}
}
private MeasurementImageClient.UploadResult uploadImage(AuthenticatedMeasurementApp app,
InboundOrderLookupEntity inbound,
MultipartFile image) {
try {
ApiResponse<MeasurementImageClient.UploadResult> response = imageClient.upload(
internalToken, app.subjectId(), app.tenantId(), inbound.getId(), image);
if (response == null || !response.successful() || response.data() == null
|| response.data().fileId() == null) {
throw downstream(response, "测量图片保存失败");
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,测量数据未保存");
}
}
private BizException downstream(ApiResponse<?> response, String fallback) {
if (response == null) return new BizException(ResultCode.SERVICE_UNAVAILABLE, fallback);
String message = response.message() == null || response.message().isBlank() ? fallback : response.message();
return new BizException(ResultCode.of(response.code()), message);
}
private InboundMeasurementEntity buildMeasurement(AuthenticatedMeasurementApp app,
CallbackRequest request, String rawBody, long inboundId) {
CallbackRequest request, String rawData,
String requestHash, long inboundId) {
BigDecimal unitRaw = unitRawVolume(request);
BigDecimal totalRaw = unitRaw.multiply(BigDecimal.valueOf(request.packageCount()))
.setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitRaw.setScale(4, RoundingMode.CEILING)
.multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.CEILING);
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(IdWorker.getId());
measurement.setTenantId(app.tenantId());
@@ -146,45 +229,65 @@ public class InboundMeasurementOpenApiService {
measurement.setSourceType("THIRD_PARTY");
measurement.setSourceCode(app.appKey());
measurement.setExternalEventNo(request.eventNo());
measurement.setExternalRequestHash(requestHash);
measurement.setDeviceCode(request.deviceCode());
measurement.setWarehouseCode(request.warehouseCode());
measurement.setMeasuredAt(request.measuredAt().withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime());
measurement.setStatus("RECEIVED");
measurement.setRawPayloadJson(rawBody);
measurement.setTotalPackageCount(request.items().stream().mapToInt(Item::quantity).sum());
BigDecimal rawTotal = request.items().stream().map(this::rawVolume).reduce(BigDecimal.ZERO, BigDecimal::add);
measurement.setTotalVolumeRawCbm(rawTotal.setScale(6, RoundingMode.HALF_UP));
measurement.setTotalVolumeCbm(rawTotal.setScale(4, RoundingMode.CEILING));
measurement.setRawPayloadJson(rawData);
measurement.setTotalPackageCount(request.packageCount());
measurement.setTotalVolumeRawCbm(totalRaw);
measurement.setTotalVolumeCbm(totalVolume);
measurement.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
measurement.setCreatedBy(app.subjectId());
measurement.setCreatedAt(LocalDateTime.now());
measurement.setUpdatedAt(LocalDateTime.now());
measurement.setVersion(0);
return measurement;
}
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId, Item source) {
BigDecimal rawVolume = rawVolume(source);
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId,
CallbackRequest source,
OrderCargoClient.CargoBinding cargo,
long imageFileId) {
BigDecimal unitRaw = unitRawVolume(source);
BigDecimal unitVolume = unitRaw.setScale(4, RoundingMode.CEILING);
BigDecimal count = BigDecimal.valueOf(source.packageCount());
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.CEILING);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(IdWorker.getId());
item.setTenantId(tenantId);
item.setMeasurementId(measurementId);
item.setItemNo(source.itemNo());
item.setBoxSpecification(org.springframework.util.StringUtils.hasText(source.boxSpecification())
? source.boxSpecification().trim() : source.itemNo());
item.setPackageCount(source.quantity());
item.setInboundDetailId(cargo.cargoId());
item.setExternalDetailNo(source.externalDetailNo());
item.setItemNo(Integer.toString(cargo.itemNo()));
item.setBoxSpecification(source.boxSpecification().trim());
item.setPackageCount(source.packageCount());
item.setLengthCm(source.lengthCm());
item.setWidthCm(source.widthCm());
item.setHeightCm(source.heightCm());
item.setVolumeRawCbm(rawVolume.setScale(6, RoundingMode.HALF_UP));
item.setVolumeCbm(rawVolume.setScale(4, RoundingMode.CEILING));
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
item.setSourceImageFileId(imageFileId);
item.setRemark(source.remark());
item.setCreatedAt(LocalDateTime.now());
return item;
}
private BigDecimal rawVolume(Item item) {
return item.lengthCm().multiply(item.widthCm()).multiply(item.heightCm())
.multiply(BigDecimal.valueOf(item.quantity()))
.divide(CUBIC_CENTIMETERS_PER_CBM, 12, RoundingMode.HALF_UP);
private BigDecimal unitRawVolume(CallbackRequest request) {
return request.lengthCm().multiply(request.widthCm()).multiply(request.heightCm())
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
}
private EventResponse replayIdempotency(AuthenticatedMeasurementApp app, String key, String requestHash) {
@@ -193,7 +296,7 @@ public class InboundMeasurementOpenApiService {
throw new BizException(ResultCode.CONFLICT, "相同 Idempotency-Key 的请求正在处理");
}
if (!requestHash.equals(existing.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 的报文内容不一致");
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 的测量数据或图片不一致");
}
if (!"ACCEPTED".equals(existing.getProcessingStatus()) || existing.getResultJson() == null) {
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 已处理但未成功");
@@ -205,25 +308,52 @@ public class InboundMeasurementOpenApiService {
}
}
private CallbackRequest parseAndValidate(String rawBody) {
private CallbackRequest parseAndValidate(String rawData) {
try {
CallbackRequest request = objectMapper.readValue(rawBody, CallbackRequest.class);
CallbackRequest request = objectMapper.readValue(rawData, CallbackRequest.class);
Set<ConstraintViolation<CallbackRequest>> violations = validator.validate(request);
if (!violations.isEmpty()) {
ConstraintViolation<CallbackRequest> violation = violations.iterator().next();
throw new BizException(ResultCode.VALIDATION_FAILED,
violations.iterator().next().getPropertyPath() + " " + violations.iterator().next().getMessage());
violation.getPropertyPath() + " " + violation.getMessage());
}
return request;
} catch (BizException exception) {
throw exception;
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "请求 JSON 格式无效");
throw new BizException(ResultCode.BAD_REQUEST, "data 请求部分不是有效 JSON");
}
}
private EventResponse response(InboundMeasurementEntity measurement, String inboundCode) {
return new EventResponse(measurement.getExternalEventNo(), inboundCode, true,
measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC));
private void requireImage(MultipartFile image) {
if (image == null || image.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "测量图片不能为空");
if (image.getSize() > 5L * 1024 * 1024) {
throw new BizException(ResultCode.BAD_REQUEST, "测量图片不能超过5MB");
}
}
private String signedPayload(String rawData, MultipartFile image) {
try {
return (rawData == null ? "" : rawData) + "\n" + sha256Hex(image.getBytes());
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "测量图片无法读取");
}
}
private String sha256Hex(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (Exception exception) {
throw new IllegalStateException("SHA-256 不可用", exception);
}
}
private EventResponse response(InboundMeasurementEntity measurement, String inboundCode,
InboundMeasurementItemEntity item) {
return new EventResponse(measurement.getExternalEventNo(), inboundCode,
item == null ? null : item.getInboundDetailId(), measurement.getId(),
item == null ? null : item.getId(), item == null ? null : item.getSourceImageFileId(),
true, measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC));
}
private String writeJson(Object value) {
@@ -28,6 +28,13 @@ public class ThirdPartyCallLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody) {
return start(method, path, headers, rawBody,
MeasurementRequestAuthenticator.sha256Hex(rawBody));
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody,
String requestHash) {
ThirdPartyCallLogEntity log = new ThirdPartyCallLogEntity();
log.setId(IdWorker.getId());
MeasurementOpenApiProperties.AppCredential app = properties.getApps().get(headers.appKey());
@@ -40,7 +47,7 @@ public class ThirdPartyCallLogService {
log.setNonce(limit(headers.nonce(), 128));
log.setIdempotencyKey(limit(headers.idempotencyKey(), 128));
log.setSignatureValue(limit(headers.signature(), 128));
log.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(rawBody));
log.setRequestHash(requestHash);
log.setRawPayload(rawBody == null ? "" : rawBody);
log.setProcessingStatus("PROCESSING");
log.setRequestedAt(LocalDateTime.now());