From addde4375e0b672d2f49d0fe39555be6f1bfe13c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 4 Aug 2026 12:23:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=8C=E6=9C=9F=E5=86=85=E5=AE=B9=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/firstleg-service.yml | 2 + config/nacos/local/firstleg-service.yml | 2 + ...8-04-二期入库新增表字段-MySQL8一键执行.sql | 50 ++++ deploy/sql/README.md | 7 + .../InternalMeasurementImageController.java | 70 +++++ .../file/service/MeasurementImageService.java | 42 ++- .../file/service/MeasurementImageStorage.java | 10 + ...nternalMeasurementImageControllerTest.java | 43 +++ .../MeasurementImageServiceEvidenceTest.java | 63 +++++ .../client/MeasurementImageClient.java | 35 +++ .../firstleg/client/OrderCargoClient.java | 24 ++ .../OpenApiInboundMeasurementController.java | 20 +- .../firstleg/dto/OpenApiMeasurementDtos.java | 34 ++- .../entity/InboundMeasurementEntity.java | 1 + .../entity/InboundMeasurementItemEntity.java | 2 + .../entity/InboundOrderLookupEntity.java | 1 + .../InboundMeasurementOpenApiRepository.java | 15 + .../InboundMeasurementOpenApiService.java | 266 +++++++++++++----- .../service/ThirdPartyCallLogService.java | 9 +- firstleg/src/main/resources/application.yml | 10 + .../resources/db/phase2-inbound-schema.sql | 4 + ...ird-party-single-measurement-migration.sql | 46 +++ .../main/resources/openapi/firstleg-v1.yaml | 48 ++-- ...oundMeasurementControllerContractTest.java | 15 +- .../InboundMeasurementOpenApiServiceTest.java | 99 ++++++- .../InternalOrderCargoController.java | 60 ++++ .../order/repository/OrderRepository.java | 8 + .../InternalOrderCargoControllerTest.java | 43 +++ 28 files changed, 897 insertions(+), 132 deletions(-) create mode 100644 file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java create mode 100644 file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java create mode 100644 file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java create mode 100644 firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql create mode 100644 order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java create mode 100644 order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index f22fc6e4a..580aeb623 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -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 diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 1fa897405..7c5553551 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -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 diff --git a/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql b/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql index 0dd1a364f..a7f9fd5ef 100644 --- a/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql +++ b/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql @@ -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; diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d5d634534..5592c3807 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -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`。 diff --git a/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java new file mode 100644 index 000000000..4f6ded984 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java @@ -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 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 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, "内部服务凭证无效"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java index 52193661f..66b634e20 100644 --- a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java @@ -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() .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, "称重图片不存在"); diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java index 4a6078366..7dfc1cf09 100644 --- a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java @@ -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("上传内容不是有效图片"); diff --git a/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java new file mode 100644 index 000000000..8aace9cc4 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java @@ -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("凭证无效"); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java new file mode 100644 index 000000000..c631d7cf3 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java @@ -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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java new file mode 100644 index 000000000..1ce3a8f68 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java @@ -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 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 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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java new file mode 100644 index 000000000..296382585 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -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 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) { + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java index a583bdfc1..51c2b42b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java @@ -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 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 = "查询第三方测量事件状态", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java index 741950b85..249b89084 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OpenApiMeasurementDtos.java @@ -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); + } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementEntity.java index ae9816be7..e20116028 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java index 5d59e70cd..bd2eb1845 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderLookupEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderLookupEntity.java index 6c8d9e4f3..d1475f18f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderLookupEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderLookupEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java index df5fcd597..a29ff882a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java @@ -53,4 +53,19 @@ public class InboundMeasurementOpenApiRepository { items.insert(item); } + public InboundMeasurementItemEntity findSingleItem(long tenantId, long measurementId) { + return items.selectOne(new LambdaQueryWrapper() + .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() + .eq(InboundMeasurementItemEntity::getTenantId, tenantId) + .eq(InboundMeasurementItemEntity::getInboundDetailId, inboundDetailId) + .eq(InboundMeasurementItemEntity::getExternalDetailNo, externalDetailNo)); + } + } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java index 3d4ba704e..5a0b8a7cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java @@ -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 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 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 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> violations = validator.validate(request); if (!violations.isEmpty()) { + ConstraintViolation 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java index 34ba3aebc..dbf12aee9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java @@ -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()); diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index e42c5c5a4..ebcb8b2ad 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -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 diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index f6940e9a3..8a4609214 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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='入库长宽高和箱数测量明细'; diff --git a/firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql b/firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql new file mode 100644 index 000000000..661d40202 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql @@ -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; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e4dde5f5e..62e3ca8d5 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java index e32ba60ea..8a5f0ac5f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java @@ -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("不改变入库单状态"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java index b55d6cc0c..80c360a02 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java @@ -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 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 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) { } } diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java new file mode 100644 index 000000000..9cc81ab29 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -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 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) { + } +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 278576bc0..39d840a85 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -106,6 +106,14 @@ public class OrderRepository { .orderByAsc(OrderCargoEntity::getItemNo)); } + public OrderCargoEntity findCargo(long tenantId, long orderId, long cargoId) { + return cargoMapper.selectOne(new LambdaQueryWrapper() + .eq(OrderCargoEntity::getTenantId, tenantId) + .eq(OrderCargoEntity::getOrderId, orderId) + .eq(OrderCargoEntity::getId, cargoId) + .eq(OrderCargoEntity::getDeleted, false)); + } + public List findParties(long tenantId, long orderId) { return partyMapper.selectList(new LambdaQueryWrapper() .eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId)); diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java new file mode 100644 index 000000000..95321bf92 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -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("凭证无效"); + } +}