二期内容提交
This commit is contained in:
@@ -5,6 +5,8 @@ spring:
|
|||||||
password: ${FIRSTLEG_DB_PASSWORD}
|
password: ${FIRSTLEG_DB_PASSWORD}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
firstleg:
|
firstleg:
|
||||||
inbound:
|
inbound:
|
||||||
allow-rejected-measurement: false
|
allow-rejected-measurement: false
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ spring.datasource:
|
|||||||
username: ${FIRSTLEG_DB_USERNAME:root}
|
username: ${FIRSTLEG_DB_USERNAME:root}
|
||||||
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
firstleg:
|
firstleg:
|
||||||
inbound:
|
inbound:
|
||||||
allow-rejected-measurement: false
|
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;
|
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,
|
SELECT 'firstleg 二期核心表数量' AS check_item,
|
||||||
COUNT(*) AS actual_value,
|
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'))
|
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;
|
SELECT '二期入库新增表与字段脚本执行完成,请确认以上检查全部为 PASS' AS execution_message;
|
||||||
|
|||||||
@@ -25,3 +25,10 @@
|
|||||||
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
|
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
|
||||||
|
|
||||||
执行结束后必须看到 `MIGRATION_COMPLETED`,并确认其前面的 IAM 权限、IAM 客户档案九字段、Warehouse 表结构、Order 幂等表和 Firstleg 索引核验均通过。
|
执行结束后必须看到 `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
|
@Transactional
|
||||||
public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
|
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);
|
var stored = storage.store(tenantId, inboundId, file);
|
||||||
FileObjectEntity object = new FileObjectEntity();
|
FileObjectEntity object = new FileObjectEntity();
|
||||||
object.setTenantId(tenantId);
|
object.setTenantId(tenantId);
|
||||||
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
|
object.setBusinessType(businessType);
|
||||||
object.setBusinessId(Long.toString(inboundId));
|
object.setBusinessId(Long.toString(inboundId));
|
||||||
object.setOriginalName(stored.originalName());
|
object.setOriginalName(stored.originalName());
|
||||||
object.setObjectKey(stored.objectKey());
|
object.setObjectKey(stored.objectKey());
|
||||||
@@ -56,12 +71,15 @@ public class MeasurementImageService {
|
|||||||
object.setDeleted(false);
|
object.setDeleted(false);
|
||||||
object.setVersion(0);
|
object.setVersion(0);
|
||||||
files.insert(object);
|
files.insert(object);
|
||||||
|
return object;
|
||||||
return recognize(object);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) {
|
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) {
|
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()));
|
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) {
|
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
|
||||||
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
|
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
|
||||||
.eq(FileObjectEntity::getTenantId, tenantId)
|
.eq(FileObjectEntity::getTenantId, tenantId)
|
||||||
.eq(FileObjectEntity::getId, fileId)
|
.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::getBusinessId, Long.toString(inboundId))
|
||||||
.eq(FileObjectEntity::getDeleted, false));
|
.eq(FileObjectEntity::getDeleted, false));
|
||||||
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
|
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, "称重图片读取失败"); }
|
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) {
|
private void verifyImage(MultipartFile file) {
|
||||||
try (InputStream input = file.getInputStream()) {
|
try (InputStream input = file.getInputStream()) {
|
||||||
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
|
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
|
||||||
|
|||||||
+43
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-9
@@ -12,19 +12,21 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
/** 第三方设备长宽高测量开放接口。 */
|
/** 第三方设备长宽高测量开放接口。 */
|
||||||
@Validated
|
@Validated
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/openapi/v1/inbound-measurements")
|
@RequestMapping("/openapi/v1/inbound-measurements")
|
||||||
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码接收长宽高结果,只落测量数据,不审核、计费或入库")
|
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码和订单货物明细接收一条箱规及一张图片,只落测量数据,不审核、计费或入库")
|
||||||
@SecurityRequirement(name = "measurementAppKey")
|
@SecurityRequirement(name = "measurementAppKey")
|
||||||
@SecurityRequirement(name = "measurementTimestamp")
|
@SecurityRequirement(name = "measurementTimestamp")
|
||||||
@SecurityRequirement(name = "measurementNonce")
|
@SecurityRequirement(name = "measurementNonce")
|
||||||
@@ -36,25 +38,25 @@ public class OpenApiInboundMeasurementController {
|
|||||||
this.service = service;
|
this.service = service;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "提交第三方长宽高测量结果",
|
@Operation(summary = "提交一条第三方箱规测量结果和图片",
|
||||||
description = "签名正文为 METHOD、PATH、时间戳、Nonce、Idempotency-Key、原始 body SHA-256 六行;只保存调用日志、测量批次和明细")
|
description = "multipart 包含 data 原始JSON和 image 图片;签名 body 为 data原文、换行、图片SHA-256,外层仍按 METHOD、PATH、时间戳、Nonce、Idempotency-Key、body SHA-256 六行签名")
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功或业务失败,按响应体 code 判断"),
|
@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 = "413", description = "请求体超过网关限制"),
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
|
||||||
})
|
})
|
||||||
@PostMapping
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ApiResponse<EventResponse> receive(
|
public ApiResponse<EventResponse> receive(
|
||||||
@RequestHeader("X-App-Key") String appKey,
|
@RequestHeader("X-App-Key") String appKey,
|
||||||
@RequestHeader("X-Timestamp") String timestamp,
|
@RequestHeader("X-Timestamp") String timestamp,
|
||||||
@RequestHeader("X-Nonce") String nonce,
|
@RequestHeader("X-Nonce") String nonce,
|
||||||
@RequestHeader("X-Signature") String signature,
|
@RequestHeader("X-Signature") String signature,
|
||||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
|
@RequestPart("data") String rawData,
|
||||||
description = "原始 UTF-8 JSON;任何空白差异都会改变签名")
|
@RequestPart("image") MultipartFile image) {
|
||||||
@RequestBody String rawBody) {
|
|
||||||
return ApiResponse.success(service.receive(
|
return ApiResponse.success(service.receive(
|
||||||
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey), rawBody));
|
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey),
|
||||||
|
rawData, image));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "查询第三方测量事件状态",
|
@Operation(summary = "查询第三方测量事件状态",
|
||||||
|
|||||||
@@ -1,65 +1,71 @@
|
|||||||
package com.baiyue.firstleg.dto;
|
package com.baiyue.firstleg.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.DecimalMax;
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
import jakarta.validation.constraints.DecimalMin;
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
import jakarta.validation.constraints.Max;
|
import jakarta.validation.constraints.Max;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** 第三方长宽高测量开放接口 DTO。 */
|
/** 第三方长宽高测量开放接口 DTO。 */
|
||||||
public final class OpenApiMeasurementDtos {
|
public final class OpenApiMeasurementDtos {
|
||||||
private OpenApiMeasurementDtos() {
|
private OpenApiMeasurementDtos() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码提交的第三方长宽高测量结果")
|
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码和订单货物明细提交一条箱规测量结果")
|
||||||
public record CallbackRequest(
|
public record CallbackRequest(
|
||||||
@NotBlank @Size(max = 128)
|
@NotBlank @Size(max = 128)
|
||||||
@Pattern(regexp = "[A-Za-z0-9._:-]+")
|
@Pattern(regexp = "[A-Za-z0-9._:-]+")
|
||||||
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
|
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
|
||||||
String eventNo,
|
String eventNo,
|
||||||
|
@NotBlank @Size(max = 128)
|
||||||
|
@Pattern(regexp = "[A-Za-z0-9._:-]+")
|
||||||
|
@Schema(description = "第三方测量明细唯一号,用于防止同一物理明细重复绑定", example = "BOX-20260801-0001")
|
||||||
|
String externalDetailNo,
|
||||||
@NotBlank @Size(max = 50)
|
@NotBlank @Size(max = 50)
|
||||||
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
|
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
|
||||||
String warehouseCode,
|
String warehouseCode,
|
||||||
@NotBlank @Size(max = 40)
|
@NotBlank @Size(max = 40)
|
||||||
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
|
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
|
||||||
String inboundCode,
|
String inboundCode,
|
||||||
|
@NotNull @Min(1)
|
||||||
|
@Schema(description = "由柏悦订单明细接口提供的货物明细ID", example = "190000000000000001")
|
||||||
|
Long inboundDetailId,
|
||||||
@NotBlank @Size(max = 64)
|
@NotBlank @Size(max = 64)
|
||||||
@Schema(description = "测量设备编码", example = "DEVICE-01")
|
@Schema(description = "测量设备编码", example = "DEVICE-01")
|
||||||
String deviceCode,
|
String deviceCode,
|
||||||
@NotNull
|
@NotNull
|
||||||
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
|
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
|
||||||
OffsetDateTime measuredAt,
|
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 lengthCm,
|
||||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
|
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
|
||||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
|
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
|
||||||
@NotNull @Min(1) @Max(1000000) Integer quantity,
|
@NotNull @Min(1) @Max(1000000) Integer packageCount,
|
||||||
@Size(max = 2048) String sourceImageUrl,
|
@DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal unitWeightKg,
|
||||||
@NotBlank @Size(max = 64) String boxSpecification) {
|
@NotBlank @Size(max = 64) String boxSpecification,
|
||||||
|
@Size(max = 500) String remark) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
|
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
|
||||||
public record EventResponse(
|
public record EventResponse(
|
||||||
String eventNo,
|
String eventNo,
|
||||||
String inboundCode,
|
String inboundCode,
|
||||||
|
Long inboundDetailId,
|
||||||
|
Long measurementId,
|
||||||
|
Long measurementItemId,
|
||||||
|
Long imageFileId,
|
||||||
boolean accepted,
|
boolean accepted,
|
||||||
String status,
|
String status,
|
||||||
OffsetDateTime receivedAt) {
|
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 sourceType;
|
||||||
private String sourceCode;
|
private String sourceCode;
|
||||||
private String externalEventNo;
|
private String externalEventNo;
|
||||||
|
private String externalRequestHash;
|
||||||
private String deviceCode;
|
private String deviceCode;
|
||||||
private String warehouseCode;
|
private String warehouseCode;
|
||||||
private LocalDateTime measuredAt;
|
private LocalDateTime measuredAt;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public class InboundMeasurementItemEntity {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
private Long measurementId;
|
private Long measurementId;
|
||||||
|
private Long inboundDetailId;
|
||||||
|
private String externalDetailNo;
|
||||||
private String itemNo;
|
private String itemNo;
|
||||||
private String boxSpecification;
|
private String boxSpecification;
|
||||||
private Integer packageCount;
|
private Integer packageCount;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class InboundOrderLookupEntity {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
private String inboundCode;
|
private String inboundCode;
|
||||||
|
private Long orderId;
|
||||||
private Long warehouseId;
|
private Long warehouseId;
|
||||||
private String processStatus;
|
private String processStatus;
|
||||||
private Integer deleted;
|
private Integer deleted;
|
||||||
|
|||||||
+15
@@ -53,4 +53,19 @@ public class InboundMeasurementOpenApiRepository {
|
|||||||
items.insert(item);
|
items.insert(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InboundMeasurementItemEntity findSingleItem(long tenantId, long measurementId) {
|
||||||
|
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||||
|
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||||
|
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public InboundMeasurementItemEntity findExternalDetail(long tenantId, long inboundDetailId,
|
||||||
|
String externalDetailNo) {
|
||||||
|
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||||
|
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||||
|
.eq(InboundMeasurementItemEntity::getInboundDetailId, inboundDetailId)
|
||||||
|
.eq(InboundMeasurementItemEntity::getExternalDetailNo, externalDetailNo));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-68
@@ -1,11 +1,13 @@
|
|||||||
package com.baiyue.firstleg.service;
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
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.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
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.CallbackRequest;
|
||||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
|
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.InboundMeasurementEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||||
@@ -15,63 +17,87 @@ import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
|
|||||||
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
|
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import feign.FeignException;
|
||||||
import jakarta.validation.ConstraintViolation;
|
import jakarta.validation.ConstraintViolation;
|
||||||
import jakarta.validation.Validator;
|
import jakarta.validation.Validator;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
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.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
import java.security.MessageDigest;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.HexFormat;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 第三方测量接收编排。只写调用日志、测量批次和明细,不提交审核且不更新入库主单。
|
* 第三方单条测量接收编排:按入仓码定位主单、按订单货物明细ID绑定来源、
|
||||||
|
* 将同一请求携带的图片保存到文件中心,再落一条测量批次和一条箱规明细。
|
||||||
|
* 本服务不提交审核、不计费且不更新入库主单。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class InboundMeasurementOpenApiService {
|
public class InboundMeasurementOpenApiService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InboundMeasurementOpenApiService.class);
|
||||||
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
|
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 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 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 InboundMeasurementOpenApiRepository repository;
|
||||||
private final MeasurementRequestAuthenticator authenticator;
|
private final MeasurementRequestAuthenticator authenticator;
|
||||||
private final ThirdPartyCallLogService callLogService;
|
private final ThirdPartyCallLogService callLogService;
|
||||||
|
private final OrderCargoClient orderCargoClient;
|
||||||
|
private final MeasurementImageClient imageClient;
|
||||||
|
private final String internalToken;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final Validator validator;
|
private final Validator validator;
|
||||||
private final TransactionTemplate transactionTemplate;
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
public InboundMeasurementOpenApiService(InboundMeasurementOpenApiRepository repository,
|
public InboundMeasurementOpenApiService(
|
||||||
MeasurementRequestAuthenticator authenticator,
|
InboundMeasurementOpenApiRepository repository,
|
||||||
ThirdPartyCallLogService callLogService,
|
MeasurementRequestAuthenticator authenticator,
|
||||||
ObjectMapper objectMapper, Validator validator,
|
ThirdPartyCallLogService callLogService,
|
||||||
TransactionTemplate transactionTemplate) {
|
OrderCargoClient orderCargoClient,
|
||||||
|
MeasurementImageClient imageClient,
|
||||||
|
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
Validator validator,
|
||||||
|
TransactionTemplate transactionTemplate) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.authenticator = authenticator;
|
this.authenticator = authenticator;
|
||||||
this.callLogService = callLogService;
|
this.callLogService = callLogService;
|
||||||
|
this.orderCargoClient = orderCargoClient;
|
||||||
|
this.imageClient = imageClient;
|
||||||
|
this.internalToken = internalToken;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.validator = validator;
|
this.validator = validator;
|
||||||
this.transactionTemplate = transactionTemplate;
|
this.transactionTemplate = transactionTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public EventResponse receive(MeasurementRequestHeaders headers, String rawBody) {
|
public EventResponse receive(MeasurementRequestHeaders headers, String rawData, MultipartFile image) {
|
||||||
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawBody);
|
requireImage(image);
|
||||||
|
String signedPayload = signedPayload(rawData, image);
|
||||||
|
String requestHash = MeasurementRequestAuthenticator.sha256Hex(signedPayload);
|
||||||
|
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawData, requestHash);
|
||||||
try {
|
try {
|
||||||
AuthenticatedMeasurementApp app = authenticator.authenticate(
|
AuthenticatedMeasurementApp app = authenticator.authenticate(
|
||||||
"POST", CALLBACK_PATH, headers, rawBody, true);
|
"POST", CALLBACK_PATH, headers, signedPayload, true);
|
||||||
CallbackRequest request = parseAndValidate(rawBody);
|
CallbackRequest request = parseAndValidate(rawData);
|
||||||
String requestHash = MeasurementRequestAuthenticator.sha256Hex(rawBody);
|
|
||||||
if (!callLogService.claimIdempotency(logId)) {
|
if (!callLogService.claimIdempotency(logId)) {
|
||||||
EventResponse replay = replayIdempotency(app, headers.idempotencyKey(), requestHash);
|
EventResponse replay = replayIdempotency(app, headers.idempotencyKey(), requestHash);
|
||||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "duplicate", writeJson(replay));
|
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "duplicate", writeJson(replay));
|
||||||
return replay;
|
return replay;
|
||||||
}
|
}
|
||||||
EventResponse result = transactionTemplate.execute(status ->
|
EventResponse result = transactionTemplate.execute(status ->
|
||||||
persist(app, request, rawBody, requestHash));
|
persist(app, request, rawData, requestHash, image));
|
||||||
String resultJson = writeJson(result);
|
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
|
||||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", resultJson);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
|
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
|
||||||
@@ -86,14 +112,11 @@ public class InboundMeasurementOpenApiService {
|
|||||||
try {
|
try {
|
||||||
AuthenticatedMeasurementApp app = authenticator.authenticate("GET", path, headers, "", false);
|
AuthenticatedMeasurementApp app = authenticator.authenticate("GET", path, headers, "", false);
|
||||||
InboundMeasurementEntity measurement = repository.findEvent(app.tenantId(), app.appKey(), eventNo);
|
InboundMeasurementEntity measurement = repository.findEvent(app.tenantId(), app.appKey(), eventNo);
|
||||||
if (measurement == null) {
|
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
|
|
||||||
}
|
|
||||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), measurement.getInboundId());
|
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), measurement.getInboundId());
|
||||||
if (inbound == null) {
|
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
|
InboundMeasurementItemEntity item = repository.findSingleItem(app.tenantId(), measurement.getId());
|
||||||
}
|
EventResponse result = response(measurement, inbound.getInboundCode(), item);
|
||||||
EventResponse result = response(measurement, inbound.getInboundCode());
|
|
||||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
|
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
|
||||||
return result;
|
return result;
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
@@ -103,23 +126,20 @@ public class InboundMeasurementOpenApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private EventResponse persist(AuthenticatedMeasurementApp app,
|
private EventResponse persist(AuthenticatedMeasurementApp app, CallbackRequest request,
|
||||||
CallbackRequest request, String rawBody, String requestHash) {
|
String rawData, String requestHash, MultipartFile image) {
|
||||||
InboundMeasurementEntity existingEvent = repository.findEvent(
|
InboundMeasurementEntity existing = repository.findEvent(app.tenantId(), app.appKey(), request.eventNo());
|
||||||
app.tenantId(), app.appKey(), request.eventNo());
|
if (existing != null) {
|
||||||
if (existingEvent != null) {
|
if (!requestHash.equals(existing.getExternalRequestHash())) {
|
||||||
if (!MeasurementRequestAuthenticator.sha256Hex(existingEvent.getRawPayloadJson()).equals(requestHash)) {
|
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的测量数据或图片不一致");
|
||||||
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的报文内容不一致");
|
|
||||||
}
|
}
|
||||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existingEvent.getInboundId());
|
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existing.getInboundId());
|
||||||
EventResponse result = response(existingEvent, inbound == null ? request.inboundCode() : inbound.getInboundCode());
|
return response(existing, inbound == null ? request.inboundCode() : inbound.getInboundCode(),
|
||||||
return result;
|
repository.findSingleItem(app.tenantId(), existing.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Long boundWarehouseId = app.warehouseBindings().get(request.warehouseCode());
|
Long boundWarehouseId = app.warehouseBindings().get(request.warehouseCode());
|
||||||
if (boundWarehouseId == null) {
|
if (boundWarehouseId == null) throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
|
||||||
throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
|
|
||||||
}
|
|
||||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), request.inboundCode());
|
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), request.inboundCode());
|
||||||
if (inbound == null || !boundWarehouseId.equals(inbound.getWarehouseId())) {
|
if (inbound == null || !boundWarehouseId.equals(inbound.getWarehouseId())) {
|
||||||
throw new BizException(ResultCode.NOT_FOUND, "入仓码不存在或不属于绑定仓库");
|
throw new BizException(ResultCode.NOT_FOUND, "入仓码不存在或不属于绑定仓库");
|
||||||
@@ -128,17 +148,80 @@ public class InboundMeasurementOpenApiService {
|
|||||||
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许接收测量结果");
|
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许接收测量结果");
|
||||||
}
|
}
|
||||||
|
|
||||||
InboundMeasurementEntity measurement = buildMeasurement(app, request, rawBody, inbound.getId());
|
OrderCargoClient.CargoBinding cargo = requireCargo(app.tenantId(), inbound, request.inboundDetailId());
|
||||||
repository.insertMeasurement(measurement);
|
if (repository.findExternalDetail(app.tenantId(), cargo.cargoId(), request.externalDetailNo()) != null) {
|
||||||
for (Item item : request.items()) {
|
throw new BizException(ResultCode.CONFLICT, "该订单明细已绑定相同第三方测量明细号");
|
||||||
repository.insertItem(buildItem(app.tenantId(), measurement.getId(), item));
|
|
||||||
}
|
}
|
||||||
EventResponse result = response(measurement, request.inboundCode());
|
MeasurementImageClient.UploadResult uploaded = uploadImage(app, inbound, image);
|
||||||
return result;
|
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,
|
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();
|
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||||
measurement.setId(IdWorker.getId());
|
measurement.setId(IdWorker.getId());
|
||||||
measurement.setTenantId(app.tenantId());
|
measurement.setTenantId(app.tenantId());
|
||||||
@@ -146,45 +229,65 @@ public class InboundMeasurementOpenApiService {
|
|||||||
measurement.setSourceType("THIRD_PARTY");
|
measurement.setSourceType("THIRD_PARTY");
|
||||||
measurement.setSourceCode(app.appKey());
|
measurement.setSourceCode(app.appKey());
|
||||||
measurement.setExternalEventNo(request.eventNo());
|
measurement.setExternalEventNo(request.eventNo());
|
||||||
|
measurement.setExternalRequestHash(requestHash);
|
||||||
measurement.setDeviceCode(request.deviceCode());
|
measurement.setDeviceCode(request.deviceCode());
|
||||||
measurement.setWarehouseCode(request.warehouseCode());
|
measurement.setWarehouseCode(request.warehouseCode());
|
||||||
measurement.setMeasuredAt(request.measuredAt().withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime());
|
measurement.setMeasuredAt(request.measuredAt().withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime());
|
||||||
measurement.setStatus("RECEIVED");
|
measurement.setStatus("RECEIVED");
|
||||||
measurement.setRawPayloadJson(rawBody);
|
measurement.setRawPayloadJson(rawData);
|
||||||
measurement.setTotalPackageCount(request.items().stream().mapToInt(Item::quantity).sum());
|
measurement.setTotalPackageCount(request.packageCount());
|
||||||
BigDecimal rawTotal = request.items().stream().map(this::rawVolume).reduce(BigDecimal.ZERO, BigDecimal::add);
|
measurement.setTotalVolumeRawCbm(totalRaw);
|
||||||
measurement.setTotalVolumeRawCbm(rawTotal.setScale(6, RoundingMode.HALF_UP));
|
measurement.setTotalVolumeCbm(totalVolume);
|
||||||
measurement.setTotalVolumeCbm(rawTotal.setScale(4, RoundingMode.CEILING));
|
measurement.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||||
measurement.setCreatedBy(app.subjectId());
|
measurement.setCreatedBy(app.subjectId());
|
||||||
measurement.setCreatedAt(LocalDateTime.now());
|
measurement.setCreatedAt(LocalDateTime.now());
|
||||||
measurement.setUpdatedAt(LocalDateTime.now());
|
measurement.setUpdatedAt(LocalDateTime.now());
|
||||||
|
measurement.setVersion(0);
|
||||||
return measurement;
|
return measurement;
|
||||||
}
|
}
|
||||||
|
|
||||||
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId, Item source) {
|
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId,
|
||||||
BigDecimal rawVolume = rawVolume(source);
|
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();
|
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||||
item.setId(IdWorker.getId());
|
item.setId(IdWorker.getId());
|
||||||
item.setTenantId(tenantId);
|
item.setTenantId(tenantId);
|
||||||
item.setMeasurementId(measurementId);
|
item.setMeasurementId(measurementId);
|
||||||
item.setItemNo(source.itemNo());
|
item.setInboundDetailId(cargo.cargoId());
|
||||||
item.setBoxSpecification(org.springframework.util.StringUtils.hasText(source.boxSpecification())
|
item.setExternalDetailNo(source.externalDetailNo());
|
||||||
? source.boxSpecification().trim() : source.itemNo());
|
item.setItemNo(Integer.toString(cargo.itemNo()));
|
||||||
item.setPackageCount(source.quantity());
|
item.setBoxSpecification(source.boxSpecification().trim());
|
||||||
|
item.setPackageCount(source.packageCount());
|
||||||
item.setLengthCm(source.lengthCm());
|
item.setLengthCm(source.lengthCm());
|
||||||
item.setWidthCm(source.widthCm());
|
item.setWidthCm(source.widthCm());
|
||||||
item.setHeightCm(source.heightCm());
|
item.setHeightCm(source.heightCm());
|
||||||
item.setVolumeRawCbm(rawVolume.setScale(6, RoundingMode.HALF_UP));
|
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
|
||||||
item.setVolumeCbm(rawVolume.setScale(4, RoundingMode.CEILING));
|
.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.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
|
||||||
|
item.setSourceImageFileId(imageFileId);
|
||||||
|
item.setRemark(source.remark());
|
||||||
item.setCreatedAt(LocalDateTime.now());
|
item.setCreatedAt(LocalDateTime.now());
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal rawVolume(Item item) {
|
private BigDecimal unitRawVolume(CallbackRequest request) {
|
||||||
return item.lengthCm().multiply(item.widthCm()).multiply(item.heightCm())
|
return request.lengthCm().multiply(request.widthCm()).multiply(request.heightCm())
|
||||||
.multiply(BigDecimal.valueOf(item.quantity()))
|
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
|
||||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 12, RoundingMode.HALF_UP);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private EventResponse replayIdempotency(AuthenticatedMeasurementApp app, String key, String requestHash) {
|
private EventResponse replayIdempotency(AuthenticatedMeasurementApp app, String key, String requestHash) {
|
||||||
@@ -193,7 +296,7 @@ public class InboundMeasurementOpenApiService {
|
|||||||
throw new BizException(ResultCode.CONFLICT, "相同 Idempotency-Key 的请求正在处理");
|
throw new BizException(ResultCode.CONFLICT, "相同 Idempotency-Key 的请求正在处理");
|
||||||
}
|
}
|
||||||
if (!requestHash.equals(existing.getRequestHash())) {
|
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) {
|
if (!"ACCEPTED".equals(existing.getProcessingStatus()) || existing.getResultJson() == null) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 已处理但未成功");
|
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 {
|
try {
|
||||||
CallbackRequest request = objectMapper.readValue(rawBody, CallbackRequest.class);
|
CallbackRequest request = objectMapper.readValue(rawData, CallbackRequest.class);
|
||||||
Set<ConstraintViolation<CallbackRequest>> violations = validator.validate(request);
|
Set<ConstraintViolation<CallbackRequest>> violations = validator.validate(request);
|
||||||
if (!violations.isEmpty()) {
|
if (!violations.isEmpty()) {
|
||||||
|
ConstraintViolation<CallbackRequest> violation = violations.iterator().next();
|
||||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||||
violations.iterator().next().getPropertyPath() + " " + violations.iterator().next().getMessage());
|
violation.getPropertyPath() + " " + violation.getMessage());
|
||||||
}
|
}
|
||||||
return request;
|
return request;
|
||||||
} catch (BizException exception) {
|
} catch (BizException exception) {
|
||||||
throw exception;
|
throw exception;
|
||||||
} catch (JsonProcessingException 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) {
|
private void requireImage(MultipartFile image) {
|
||||||
return new EventResponse(measurement.getExternalEventNo(), inboundCode, true,
|
if (image == null || image.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "测量图片不能为空");
|
||||||
measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC));
|
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) {
|
private String writeJson(Object value) {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public class ThirdPartyCallLogService {
|
|||||||
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody) {
|
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();
|
ThirdPartyCallLogEntity log = new ThirdPartyCallLogEntity();
|
||||||
log.setId(IdWorker.getId());
|
log.setId(IdWorker.getId());
|
||||||
MeasurementOpenApiProperties.AppCredential app = properties.getApps().get(headers.appKey());
|
MeasurementOpenApiProperties.AppCredential app = properties.getApps().get(headers.appKey());
|
||||||
@@ -40,7 +47,7 @@ public class ThirdPartyCallLogService {
|
|||||||
log.setNonce(limit(headers.nonce(), 128));
|
log.setNonce(limit(headers.nonce(), 128));
|
||||||
log.setIdempotencyKey(limit(headers.idempotencyKey(), 128));
|
log.setIdempotencyKey(limit(headers.idempotencyKey(), 128));
|
||||||
log.setSignatureValue(limit(headers.signature(), 128));
|
log.setSignatureValue(limit(headers.signature(), 128));
|
||||||
log.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(rawBody));
|
log.setRequestHash(requestHash);
|
||||||
log.setRawPayload(rawBody == null ? "" : rawBody);
|
log.setRawPayload(rawBody == null ? "" : rawBody);
|
||||||
log.setProcessingStatus("PROCESSING");
|
log.setProcessingStatus("PROCESSING");
|
||||||
log.setRequestedAt(LocalDateTime.now());
|
log.setRequestedAt(LocalDateTime.now());
|
||||||
|
|||||||
@@ -20,9 +20,19 @@ spring:
|
|||||||
group: ${NACOS_GROUP:BAIYUE}
|
group: ${NACOS_GROUP:BAIYUE}
|
||||||
namespace: ${NACOS_NAMESPACE:public}
|
namespace: ${NACOS_NAMESPACE:public}
|
||||||
config.namespace: ${NACOS_NAMESPACE:public}
|
config.namespace: ${NACOS_NAMESPACE:public}
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 5MB
|
||||||
|
max-request-size: 6MB
|
||||||
baiyue:
|
baiyue:
|
||||||
security:
|
security:
|
||||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
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:
|
firstleg:
|
||||||
inbound:
|
inbound:
|
||||||
allow-rejected-measurement: false
|
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_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
|
||||||
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
|
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
|
||||||
external_event_no VARCHAR(128) NULL COMMENT '第三方事件号',
|
external_event_no VARCHAR(128) NULL COMMENT '第三方事件号',
|
||||||
|
external_request_hash CHAR(64) NULL COMMENT '第三方data原文和图片内容联合请求哈希',
|
||||||
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
|
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
|
||||||
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
|
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
|
||||||
measured_at DATETIME(3) 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',
|
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||||
measurement_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 '批次内明细号',
|
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
|
||||||
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
|
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
|
||||||
package_count INT UNSIGNED 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 '创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
|
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)
|
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
|
||||||
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
|
) 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: 无分配权限}
|
'403': {description: 无分配权限}
|
||||||
/openapi/v1/inbound-measurements:
|
/openapi/v1/inbound-measurements:
|
||||||
post:
|
post:
|
||||||
summary: 提交第三方长宽高测量结果
|
summary: 提交一条第三方箱规测量结果和一张图片
|
||||||
description: >-
|
description: >-
|
||||||
只写 fl_third_party_call_log、fl_inbound_measurement、fl_inbound_measurement_item
|
data 中的 inboundCode 匹配入库单,inboundDetailId 必须匹配该入库单关联订单中的已有货物明细;
|
||||||
和 HTTP 幂等结果,不审核、不计费、不入库,也不改变入库单状态。
|
每次只保存一条箱规明细和一张图片,不审核、不计费、不入库,也不改变入库单状态。
|
||||||
签名串依次为 METHOD、原始 PATH、X-Timestamp、X-Nonce、Idempotency-Key、
|
签名串依次为 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 写操作;
|
三层语义:Nonce 防短时重放;Idempotency-Key 标识一次 HTTP 写操作;
|
||||||
appKey + eventNo 标识第三方业务事件。
|
appKey + eventNo 标识第三方业务事件。
|
||||||
security:
|
security:
|
||||||
@@ -89,8 +90,18 @@ paths:
|
|||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
multipart/form-data:
|
||||||
schema: {$ref: '#/components/schemas/InboundMeasurementCallbackRequest'}
|
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:
|
responses:
|
||||||
'200': {description: 成功或业务失败,按响应体字符串 code 判断;非法签名返回 A0401}
|
'200': {description: 成功或业务失败,按响应体字符串 code 判断;非法签名返回 A0401}
|
||||||
'413': {description: 请求体超过网关限制}
|
'413': {description: 请求体超过网关限制}
|
||||||
@@ -242,34 +253,31 @@ components:
|
|||||||
schemas:
|
schemas:
|
||||||
InboundMeasurementCallbackRequest:
|
InboundMeasurementCallbackRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [eventNo, warehouseCode, inboundCode, deviceCode, measuredAt, items]
|
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
|
||||||
properties:
|
properties:
|
||||||
eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001}
|
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}
|
warehouseCode: {type: string, maxLength: 50, example: CN-YW-01}
|
||||||
inboundCode: {type: string, maxLength: 40, example: RK202608010001}
|
inboundCode: {type: string, maxLength: 40, example: RK202608010001}
|
||||||
|
inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID}
|
||||||
deviceCode: {type: string, maxLength: 64, example: DEVICE-01}
|
deviceCode: {type: string, maxLength: 64, example: DEVICE-01}
|
||||||
measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'}
|
measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'}
|
||||||
items:
|
boxSpecification: {type: string, maxLength: 64, example: '40x30x47cm'}
|
||||||
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: 入库单绑定的箱规明细}
|
|
||||||
lengthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 50.20}
|
lengthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 50.20}
|
||||||
widthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 40.10}
|
widthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 40.10}
|
||||||
heightCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 30.00}
|
heightCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 30.00}
|
||||||
quantity: {type: integer, minimum: 1, maximum: 1000000, example: 5}
|
packageCount: {type: integer, minimum: 1, maximum: 1000000, example: 50}
|
||||||
sourceImageUrl: {type: string, maxLength: 2048, nullable: true, description: 仅随原始报文留档,不直接写文件中心}
|
unitWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, example: 20}
|
||||||
|
remark: {type: string, maxLength: 500, nullable: true}
|
||||||
InboundMeasurementEventResponse:
|
InboundMeasurementEventResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
eventNo: {type: string}
|
eventNo: {type: string}
|
||||||
inboundCode: {type: string}
|
inboundCode: {type: string}
|
||||||
|
inboundDetailId: {type: string}
|
||||||
|
measurementId: {type: string}
|
||||||
|
measurementItemId: {type: string}
|
||||||
|
imageFileId: {type: string}
|
||||||
accepted: {type: boolean}
|
accepted: {type: boolean}
|
||||||
status: {type: string, enum: [RECEIVED]}
|
status: {type: string, enum: [RECEIVED]}
|
||||||
receivedAt: {type: string, format: date-time}
|
receivedAt: {type: string, format: date-time}
|
||||||
|
|||||||
+10
-5
@@ -3,7 +3,8 @@ package com.baiyue.firstleg.controller;
|
|||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
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.lang.reflect.Method;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -13,12 +14,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@DisplayName("第三方测量开放接口契约")
|
@DisplayName("第三方测量开放接口契约")
|
||||||
class OpenApiInboundMeasurementControllerContractTest {
|
class OpenApiInboundMeasurementControllerContractTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("回调以原始字符串接收报文,保证签名正文不被重新序列化")
|
@DisplayName("回调接收原始data和唯一图片,保证签名与一对一绑定")
|
||||||
void shouldReceiveRawRequestBody() throws Exception {
|
void shouldReceiveRawDataAndImage() throws Exception {
|
||||||
Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receive",
|
Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receive",
|
||||||
String.class, String.class, String.class, String.class, String.class, String.class);
|
String.class, String.class, String.class, String.class, String.class,
|
||||||
assertThat(method.getParameters()[5].getAnnotation(RequestBody.class)).isNotNull();
|
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()[5].getType()).isEqualTo(String.class);
|
||||||
|
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class).value()).isEqualTo("image");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -33,6 +36,8 @@ class OpenApiInboundMeasurementControllerContractTest {
|
|||||||
.contains("Idempotency-Key")
|
.contains("Idempotency-Key")
|
||||||
.contains("appKey + eventNo")
|
.contains("appKey + eventNo")
|
||||||
.contains("Nonce")
|
.contains("Nonce")
|
||||||
|
.contains("inboundDetailId")
|
||||||
|
.contains("multipart/form-data")
|
||||||
.contains("不改变入库单状态");
|
.contains("不改变入库单状态");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-10
@@ -1,7 +1,11 @@
|
|||||||
package com.baiyue.firstleg.service;
|
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.dto.OpenApiMeasurementDtos.EventResponse;
|
||||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||||
|
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||||
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
|
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
|
||||||
import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository;
|
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.TransactionStatus;
|
||||||
import org.springframework.transaction.support.TransactionCallback;
|
import org.springframework.transaction.support.TransactionCallback;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
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.verify;
|
||||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
@DisplayName("第三方测量开放接口服务")
|
@DisplayName("第三方测量开放接口服务")
|
||||||
class InboundMeasurementOpenApiServiceTest {
|
class InboundMeasurementOpenApiServiceTest {
|
||||||
private static final String BODY = """
|
private static final String BODY = """
|
||||||
{"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001",
|
{"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001",
|
||||||
"deviceCode":"DEVICE-01","measuredAt":"2026-08-01T10:30:00+08:00",
|
"externalDetailNo":"BOX-001","inboundDetailId":"80001","deviceCode":"DEVICE-01",
|
||||||
"items":[{"itemNo":"001","lengthCm":50.20,"widthCm":40.10,"heightCm":30.00,
|
"measuredAt":"2026-08-01T10:30:00+08:00","lengthCm":50.20,"widthCm":40.10,
|
||||||
"quantity":5,"boxSpecification":"50.2x40.1x30cm"}]}
|
"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
|
@Test
|
||||||
@DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口")
|
@DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口")
|
||||||
@@ -47,12 +58,23 @@ class InboundMeasurementOpenApiServiceTest {
|
|||||||
InboundOrderLookupEntity inbound = inbound();
|
InboundOrderLookupEntity inbound = inbound();
|
||||||
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(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.accepted()).isTrue();
|
||||||
assertThat(response.status()).isEqualTo("RECEIVED");
|
assertThat(response.status()).isEqualTo("RECEIVED");
|
||||||
verify(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class));
|
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(() ->
|
assertThatNoException().isThrownBy(() ->
|
||||||
InboundMeasurementOpenApiRepository.class.getDeclaredMethod("findInbound", long.class, String.class));
|
InboundMeasurementOpenApiRepository.class.getDeclaredMethod("findInbound", long.class, String.class));
|
||||||
assertThat(InboundMeasurementOpenApiRepository.class.getDeclaredMethods())
|
assertThat(InboundMeasurementOpenApiRepository.class.getDeclaredMethods())
|
||||||
@@ -67,38 +89,82 @@ class InboundMeasurementOpenApiServiceTest {
|
|||||||
EventResponse first = new EventResponse("M202608010001", "RK202608010001", true,
|
EventResponse first = new EventResponse("M202608010001", "RK202608010001", true,
|
||||||
"RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z"));
|
"RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z"));
|
||||||
ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity();
|
ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity();
|
||||||
existing.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(BODY));
|
existing.setRequestHash(requestHash());
|
||||||
existing.setResultJson(mapper.writeValueAsString(first));
|
existing.setResultJson(mapper.writeValueAsString(first));
|
||||||
existing.setProcessingStatus("ACCEPTED");
|
existing.setProcessingStatus("ACCEPTED");
|
||||||
when(fixture.logs.claimIdempotency(1L)).thenReturn(false);
|
when(fixture.logs.claimIdempotency(1L)).thenReturn(false);
|
||||||
when(fixture.logs.findIdempotencyOwner(10001L, "device-provider", "idem-1")).thenReturn(existing);
|
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);
|
assertThat(duplicate).isEqualTo(first);
|
||||||
verify(fixture.repository, never()).insertMeasurement(any());
|
verify(fixture.repository, never()).insertMeasurement(any());
|
||||||
verify(fixture.repository, never()).insertItem(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() {
|
private Fixture fixture() {
|
||||||
InboundMeasurementOpenApiRepository repository = mock(InboundMeasurementOpenApiRepository.class);
|
InboundMeasurementOpenApiRepository repository = mock(InboundMeasurementOpenApiRepository.class);
|
||||||
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
|
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
|
||||||
ThirdPartyCallLogService logs = mock(ThirdPartyCallLogService.class);
|
ThirdPartyCallLogService logs = mock(ThirdPartyCallLogService.class);
|
||||||
TransactionTemplate transactions = mock(TransactionTemplate.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(logs.claimIdempotency(1L)).thenReturn(true);
|
||||||
when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean()))
|
when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean()))
|
||||||
.thenReturn(new AuthenticatedMeasurementApp("device-provider", 10001L, 90001L,
|
.thenReturn(new AuthenticatedMeasurementApp("device-provider", 10001L, 90001L,
|
||||||
Map.of("CN-YW-01", 30001L)));
|
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 -> {
|
when(transactions.execute(any())).thenAnswer(invocation -> {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
TransactionCallback<EventResponse> callback = invocation.getArgument(0);
|
TransactionCallback<EventResponse> callback = invocation.getArgument(0);
|
||||||
return callback.doInTransaction(mock(TransactionStatus.class));
|
return callback.doInTransaction(mock(TransactionStatus.class));
|
||||||
});
|
});
|
||||||
InboundMeasurementOpenApiService service = new InboundMeasurementOpenApiService(repository,
|
InboundMeasurementOpenApiService service = new InboundMeasurementOpenApiService(repository,
|
||||||
authenticator, logs, objectMapper(),
|
authenticator, logs, orderCargo, images, "internal-token", objectMapper(),
|
||||||
Validation.buildDefaultValidatorFactory().getValidator(), transactions);
|
Validation.buildDefaultValidatorFactory().getValidator(), transactions);
|
||||||
return new Fixture(repository, logs, service);
|
return new Fixture(repository, logs, orderCargo, images, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectMapper objectMapper() {
|
private ObjectMapper objectMapper() {
|
||||||
@@ -110,6 +176,7 @@ class InboundMeasurementOpenApiServiceTest {
|
|||||||
inbound.setId(70001L);
|
inbound.setId(70001L);
|
||||||
inbound.setTenantId(10001L);
|
inbound.setTenantId(10001L);
|
||||||
inbound.setInboundCode("RK202608010001");
|
inbound.setInboundCode("RK202608010001");
|
||||||
|
inbound.setOrderId(60001L);
|
||||||
inbound.setWarehouseId(30001L);
|
inbound.setWarehouseId(30001L);
|
||||||
inbound.setProcessStatus("WAIT_MEASURE");
|
inbound.setProcessStatus("WAIT_MEASURE");
|
||||||
inbound.setDeleted(0);
|
inbound.setDeleted(0);
|
||||||
@@ -120,8 +187,20 @@ class InboundMeasurementOpenApiServiceTest {
|
|||||||
return new MeasurementRequestHeaders("device-provider", "1785551400000", nonce, "signature", "idem-1");
|
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,
|
private record Fixture(InboundMeasurementOpenApiRepository repository,
|
||||||
ThirdPartyCallLogService logs,
|
ThirdPartyCallLogService logs,
|
||||||
|
OrderCargoClient orderCargo,
|
||||||
|
MeasurementImageClient images,
|
||||||
InboundMeasurementOpenApiService service) {
|
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));
|
.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) {
|
public List<OrderPartyEntity> findParties(long tenantId, long orderId) {
|
||||||
return partyMapper.selectList(new LambdaQueryWrapper<OrderPartyEntity>()
|
return partyMapper.selectList(new LambdaQueryWrapper<OrderPartyEntity>()
|
||||||
.eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId));
|
.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("凭证无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user