二期内容提交

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
+2
View File
@@ -5,6 +5,8 @@ spring:
password: ${FIRSTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
+2
View File
@@ -4,6 +4,8 @@ spring.datasource:
username: ${FIRSTLEG_DB_USERNAME:root}
password: ${FIRSTLEG_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
@@ -1178,6 +1178,39 @@ SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_sc
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
-- 第三方单条箱规 + 单张图片绑定增量。
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
DELIMITER $$
CREATE PROCEDURE phase2_add_measurement_column(
IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase2_add_measurement_column('fl_inbound_measurement', 'external_request_hash',
'CHAR(64) NULL COMMENT ''第三方data原文和图片内容联合请求哈希'' AFTER external_event_no');
CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'inbound_detail_id',
'BIGINT UNSIGNED NULL COMMENT ''绑定的订单货物明细ID,平台手工明细可为空'' AFTER measurement_id');
CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'external_detail_no',
'VARCHAR(128) NULL COMMENT ''第三方测量明细唯一号,平台手工明细可为空'' AFTER inbound_detail_id');
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
SET @has_index = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_measurement_item'
AND INDEX_NAME = 'uk_measure_external_detail');
SET @ddl = IF(@has_index = 0,
'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no)',
'SELECT 1');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
-- 结构核对:只读输出,不写业务数据。
SELECT 'firstleg 二期核心表数量' AS check_item,
COUNT(*) AS actual_value,
@@ -1228,4 +1261,21 @@ WHERE table_schema = 'baiyue_warehouse'
OR (table_name = 'wh_location_occupancy' AND index_name IN ('uk_location_active_occupancy', 'uk_pallet_active_occupancy'))
);
SELECT '第三方单条测量绑定关键列' AS check_item,
COUNT(*) AS actual_value,
IF(COUNT(*) = 3, 'PASS', 'FAIL') AS check_result
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND ((table_name = 'fl_inbound_measurement' AND column_name = 'external_request_hash')
OR (table_name = 'fl_inbound_measurement_item'
AND column_name IN ('inbound_detail_id', 'external_detail_no')));
SELECT '第三方单条测量绑定唯一索引' AS check_item,
COUNT(DISTINCT index_name) AS actual_value,
IF(COUNT(DISTINCT index_name) = 1, 'PASS', 'FAIL') AS check_result
FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_measurement_item'
AND index_name = 'uk_measure_external_detail';
SELECT '二期入库新增表与字段脚本执行完成,请确认以上检查全部为 PASS' AS execution_message;
+7
View File
@@ -25,3 +25,10 @@
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
执行结束后必须看到 `MIGRATION_COMPLETED`,并确认其前面的 IAM 权限、IAM 客户档案九字段、Warehouse 表结构、Order 幂等表和 Firstleg 索引核验均通过。
## 二期第三方测量增量
- 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。
- 二期统一脚本:`2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` 已内联该增量。
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
- 本次代码回归不执行真实数据库迁移;测试环境部署 Firstleg 新版本前必须先执行该增量并确认末尾两项检查为 `PASS`。
@@ -0,0 +1,70 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.MeasurementImageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Firstleg 保存第三方尺寸测量证据图片的内部入口。 */
@RestController
@RequestMapping("/internal/file/v1/measurement-images")
public class InternalMeasurementImageController {
private final MeasurementImageService images;
private final byte[] expectedToken;
public InternalMeasurementImageController(
MeasurementImageService images,
@Value("${baiyue.security.internal-service-token}") String token) {
this.images = images;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<MeasurementImageService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
}
return ApiResponse.success(
images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file));
}
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<Void> delete(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单、文件和操作主体ID必须大于0");
}
images.deleteMeasurementEvidence(tenantId, inboundId, fileId, operatorId);
return ApiResponse.success(null);
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -36,10 +36,25 @@ public class MeasurementImageService {
@Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
return recognize(storeObject(tenantId, inboundId, operatorId, file, "INBOUND_WEIGHT_IMAGE"));
}
/** 保存第三方尺寸测量证据图片,不触发重量 OCR。 */
@Transactional
public UploadResult uploadMeasurementEvidence(long tenantId, long inboundId, long operatorId,
MultipartFile file) {
FileObjectEntity object = storeObject(
tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE");
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null);
}
private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
MultipartFile file, String businessType) {
var stored = storage.store(tenantId, inboundId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
object.setBusinessType(businessType);
object.setBusinessId(Long.toString(inboundId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
@@ -56,12 +71,15 @@ public class MeasurementImageService {
object.setDeleted(false);
object.setVersion(0);
files.insert(object);
return recognize(object);
return object;
}
public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) {
return recognize(requireObject(tenantId, inboundId, fileId));
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!"INBOUND_WEIGHT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "尺寸测量证据图片不执行重量OCR");
}
return recognize(object);
}
public Download download(long tenantId, long inboundId, long fileId) {
@@ -69,11 +87,25 @@ public class MeasurementImageService {
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
@Transactional
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!"INBOUND_MEASUREMENT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理第三方尺寸测量证据图片");
}
object.setDeleted(true);
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
object.setUpdatedAt(LocalDateTime.now());
files.updateById(object);
storage.delete(object.getObjectKey());
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE")
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
@@ -56,6 +56,16 @@ public class MeasurementImageStorage {
catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "称重图片读取失败"); }
}
public void delete(String objectKey) {
Path path = root.resolve(objectKey).normalize();
if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "测量图片清理失败");
}
}
private void verifyImage(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
@@ -0,0 +1,43 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.MeasurementImageService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InternalMeasurementImageControllerTest {
private final MeasurementImageService images = mock(MeasurementImageService.class);
private final InternalMeasurementImageController controller =
new InternalMeasurementImageController(images, "internal-token");
@Test
void 保存第三方尺寸测量图片但不触发重量识别契约() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "measure.png",
MediaType.IMAGE_PNG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null);
when(images.uploadMeasurementEvidence(11L, 21L, 31L, file)).thenReturn(result);
var response = controller.upload("internal-token", 31L, 11L, 21L, file);
assertThat(response.data().fileId()).isEqualTo(41L);
assertThat(response.data().recognitionStatus()).isEqualTo("NOT_REQUIRED");
verify(images).uploadMeasurementEvidence(11L, 21L, 31L, file);
}
@Test
void 内部令牌错误时拒绝保存() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1});
assertThatThrownBy(() -> controller.upload("bad-token", 31L, 11L, 21L, file))
.isInstanceOf(BizException.class)
.hasMessageContaining("凭证无效");
}
}
@@ -0,0 +1,63 @@
package com.baiyue.file.service;
import com.baiyue.file.client.WeightOcrClient;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MeasurementImageServiceEvidenceTest {
@Test
void 尺寸测量证据图片落库但不调用重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
when(storage.store(11L, 21L, image)).thenReturn(new MeasurementImageStorage.Stored(
"measurements/11/21/a.png", Path.of("a.png"), MediaType.IMAGE_PNG_VALUE,
3L, "hash", "measure.png"));
doAnswer(invocation -> {
FileObjectEntity object = invocation.getArgument(0);
object.setId(41L);
return 1;
}).when(files).insert(any(FileObjectEntity.class));
var result = service.uploadMeasurementEvidence(11L, 21L, 31L, image);
assertThat(result.fileId()).isEqualTo(41L);
assertThat(result.recognitionStatus()).isEqualTo("NOT_REQUIRED");
verifyNoInteractions(ocr);
}
@Test
void 尺寸测量证据图片不能通过重试入口触发重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
FileObjectEntity object = new FileObjectEntity();
object.setId(41L);
object.setTenantId(11L);
object.setBusinessType("INBOUND_MEASUREMENT_IMAGE");
object.setBusinessId("21");
when(files.selectOne(any())).thenReturn(object);
assertThatThrownBy(() -> service.retryRecognition(11L, 21L, 41L))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("不执行重量OCR");
verifyNoInteractions(ocr);
}
}
@@ -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());
@@ -20,9 +20,19 @@ spring:
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config.namespace: ${NACOS_NAMESPACE:public}
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
clients:
order:
url: ${ORDER_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
@@ -146,6 +146,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
external_event_no VARCHAR(128) NULL COMMENT '第三方事件号',
external_request_hash CHAR(64) NULL COMMENT '第三方data原文和图片内容联合请求哈希',
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
measured_at DATETIME(3) NULL COMMENT '设备测量时间',
@@ -173,6 +174,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空',
external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空',
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
@@ -197,6 +200,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no),
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
@@ -0,0 +1,46 @@
-- 二期第三方单条箱规 + 单张图片绑定增量;可重复执行。
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
DELIMITER $$
CREATE PROCEDURE phase2_add_measurement_column(
IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase2_add_measurement_column(
'fl_inbound_measurement', 'external_request_hash',
'CHAR(64) NULL COMMENT ''第三方data原文和图片内容联合请求哈希'' AFTER external_event_no');
CALL phase2_add_measurement_column(
'fl_inbound_measurement_item', 'inbound_detail_id',
'BIGINT UNSIGNED NULL COMMENT ''绑定的订单货物明细ID,平台手工明细可为空'' AFTER measurement_id');
CALL phase2_add_measurement_column(
'fl_inbound_measurement_item', 'external_detail_no',
'VARCHAR(128) NULL COMMENT ''第三方测量明细唯一号,平台手工明细可为空'' AFTER inbound_detail_id');
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
SET @has_index = (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'fl_inbound_measurement_item'
AND INDEX_NAME = 'uk_measure_external_detail'
);
SET @ddl = IF(
@has_index = 0,
'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no)',
'SELECT 1'
);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
@@ -69,12 +69,13 @@ paths:
'403': {description: 无分配权限}
/openapi/v1/inbound-measurements:
post:
summary: 提交第三方长宽高测量结果
summary: 提交一条第三方箱规测量结果和一张图片
description: >-
只写 fl_third_party_call_log、fl_inbound_measurement、fl_inbound_measurement_item
和 HTTP 幂等结果,不审核、不计费、不入库,也不改变入库单状态。
data 中的 inboundCode 匹配入库单,inboundDetailId 必须匹配该入库单关联订单中的已有货物明细;
每次只保存一条箱规明细和一张图片,不审核、不计费、不入库,也不改变入库单状态。
签名串依次为 METHOD、原始 PATH、X-Timestamp、X-Nonce、Idempotency-Key、
原始 UTF-8 body 的 SHA-256 小写十六进制,六部分使用 LF(\n)连接且末尾不追加换行。
签名body的 SHA-256 小写十六进制,六部分使用 LF(\n)连接且末尾不追加换行。
签名body = data原始UTF-8文本 + LF + image文件内容SHA-256小写十六进制。
三层语义:Nonce 防短时重放;Idempotency-Key 标识一次 HTTP 写操作;
appKey + eventNo 标识第三方业务事件。
security:
@@ -89,8 +90,18 @@ paths:
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InboundMeasurementCallbackRequest'}
multipart/form-data:
schema:
type: object
required: [data, image]
properties:
data:
type: string
description: InboundMeasurementCallbackRequest 的原始UTF-8 JSON文本
image:
type: string
format: binary
description: 当前测量明细唯一对应图片,JPG/PNG/BMP且不超过5MB
responses:
'200': {description: 成功或业务失败,按响应体字符串 code 判断;非法签名返回 A0401}
'413': {description: 请求体超过网关限制}
@@ -242,34 +253,31 @@ components:
schemas:
InboundMeasurementCallbackRequest:
type: object
required: [eventNo, warehouseCode, inboundCode, deviceCode, measuredAt, items]
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
properties:
eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001}
externalDetailNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: BOX-20260801-0001}
warehouseCode: {type: string, maxLength: 50, example: CN-YW-01}
inboundCode: {type: string, maxLength: 40, example: RK202608010001}
inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID}
deviceCode: {type: string, maxLength: 64, example: DEVICE-01}
measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'}
items:
type: array
minItems: 1
maxItems: 500
items: {$ref: '#/components/schemas/InboundMeasurementItem'}
InboundMeasurementItem:
type: object
required: [itemNo, boxSpecification, lengthCm, widthCm, heightCm, quantity]
properties:
itemNo: {type: string, maxLength: 32, example: '001'}
boxSpecification: {type: string, maxLength: 64, example: '61.5x42x42cm', description: 入库单绑定的箱规明细}
boxSpecification: {type: string, maxLength: 64, example: '40x30x47cm'}
lengthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 50.20}
widthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 40.10}
heightCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 30.00}
quantity: {type: integer, minimum: 1, maximum: 1000000, example: 5}
sourceImageUrl: {type: string, maxLength: 2048, nullable: true, description: 仅随原始报文留档,不直接写文件中心}
packageCount: {type: integer, minimum: 1, maximum: 1000000, example: 50}
unitWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, example: 20}
remark: {type: string, maxLength: 500, nullable: true}
InboundMeasurementEventResponse:
type: object
properties:
eventNo: {type: string}
inboundCode: {type: string}
inboundDetailId: {type: string}
measurementId: {type: string}
measurementItemId: {type: string}
imageFileId: {type: string}
accepted: {type: boolean}
status: {type: string, enum: [RECEIVED]}
receivedAt: {type: string, format: date-time}
@@ -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) {
}
}
@@ -0,0 +1,60 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.repository.OrderRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** 供头程履约服务校验入仓单所关联订单货物明细。 */
@RestController
@RequestMapping("/internal/order/v1/cargoes")
public class InternalOrderCargoController {
private final OrderRepository orders;
private final byte[] expectedToken;
public InternalOrderCargoController(OrderRepository orders,
@Value("${baiyue.security.internal-service-token}") String token) {
this.orders = orders;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
@GetMapping("/{tenantId}/{orderId}/{cargoId}")
public ApiResponse<CargoBinding> detail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable long orderId,
@PathVariable long cargoId) {
requireToken(token);
if (tenantId <= 0 || orderId <= 0 || cargoId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、订单和货物明细ID必须大于0");
}
OrderCargoEntity cargo = orders.findCargo(tenantId, orderId, cargoId);
if (cargo == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单");
}
return ApiResponse.success(new CargoBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(),
cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm()));
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
public record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount,
java.math.BigDecimal originalGrossWeightKg,
java.math.BigDecimal originalVolumeCbm) {
}
}
@@ -106,6 +106,14 @@ public class OrderRepository {
.orderByAsc(OrderCargoEntity::getItemNo));
}
public OrderCargoEntity findCargo(long tenantId, long orderId, long cargoId) {
return cargoMapper.selectOne(new LambdaQueryWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId)
.eq(OrderCargoEntity::getOrderId, orderId)
.eq(OrderCargoEntity::getId, cargoId)
.eq(OrderCargoEntity::getDeleted, false));
}
public List<OrderPartyEntity> findParties(long tenantId, long orderId) {
return partyMapper.selectList(new LambdaQueryWrapper<OrderPartyEntity>()
.eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId));
@@ -0,0 +1,43 @@
package com.baiyue.order.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InternalOrderCargoControllerTest {
private final OrderRepository orders = mock(OrderRepository.class);
private final InternalOrderCargoController controller =
new InternalOrderCargoController(orders, "internal-token");
@Test
void 只返回指定租户订单下的已有货物明细() {
OrderCargoEntity cargo = new OrderCargoEntity();
cargo.setId(31L);
cargo.setOrderId(21L);
cargo.setItemNo(1);
cargo.setCargoName("测试货物");
cargo.setPackageCount(50);
when(orders.findCargo(11L, 21L, 31L)).thenReturn(cargo);
var response = controller.detail("internal-token", 11L, 21L, 31L);
assertThat(response.successful()).isTrue();
assertThat(response.data().cargoId()).isEqualTo(31L);
assertThat(response.data().itemNo()).isEqualTo(1);
verify(orders).findCargo(11L, 21L, 31L);
}
@Test
void 内部令牌错误时拒绝查询() {
assertThatThrownBy(() -> controller.detail("bad-token", 11L, 21L, 31L))
.isInstanceOf(BizException.class)
.hasMessageContaining("凭证无效");
}
}