二期内容提交
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -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;
|
||||
|
||||
+15
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+198
-68
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user