二期内容提交
This commit is contained in:
@@ -5,6 +5,8 @@ spring:
|
||||
password: ${FIRSTLEG_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||
firstleg:
|
||||
inbound:
|
||||
allow-rejected-measurement: false
|
||||
|
||||
@@ -4,6 +4,8 @@ spring.datasource:
|
||||
username: ${FIRSTLEG_DB_USERNAME:root}
|
||||
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||
firstleg:
|
||||
inbound:
|
||||
allow-rejected-measurement: false
|
||||
|
||||
@@ -1178,6 +1178,39 @@ SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_sc
|
||||
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
|
||||
|
||||
-- 第三方单条箱规 + 单张图片绑定增量。
|
||||
USE baiyue_firstleg;
|
||||
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase2_add_measurement_column(
|
||||
IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column
|
||||
) THEN
|
||||
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
|
||||
PREPARE statement FROM @ddl;
|
||||
EXECUTE statement;
|
||||
DEALLOCATE PREPARE statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase2_add_measurement_column('fl_inbound_measurement', 'external_request_hash',
|
||||
'CHAR(64) NULL COMMENT ''第三方data原文和图片内容联合请求哈希'' AFTER external_event_no');
|
||||
CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'inbound_detail_id',
|
||||
'BIGINT UNSIGNED NULL COMMENT ''绑定的订单货物明细ID,平台手工明细可为空'' AFTER measurement_id');
|
||||
CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'external_detail_no',
|
||||
'VARCHAR(128) NULL COMMENT ''第三方测量明细唯一号,平台手工明细可为空'' AFTER inbound_detail_id');
|
||||
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
|
||||
SET @has_index = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_measurement_item'
|
||||
AND INDEX_NAME = 'uk_measure_external_detail');
|
||||
SET @ddl = IF(@has_index = 0,
|
||||
'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no)',
|
||||
'SELECT 1');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
|
||||
-- 结构核对:只读输出,不写业务数据。
|
||||
SELECT 'firstleg 二期核心表数量' AS check_item,
|
||||
COUNT(*) AS actual_value,
|
||||
@@ -1228,4 +1261,21 @@ WHERE table_schema = 'baiyue_warehouse'
|
||||
OR (table_name = 'wh_location_occupancy' AND index_name IN ('uk_location_active_occupancy', 'uk_pallet_active_occupancy'))
|
||||
);
|
||||
|
||||
SELECT '第三方单条测量绑定关键列' AS check_item,
|
||||
COUNT(*) AS actual_value,
|
||||
IF(COUNT(*) = 3, 'PASS', 'FAIL') AS check_result
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND ((table_name = 'fl_inbound_measurement' AND column_name = 'external_request_hash')
|
||||
OR (table_name = 'fl_inbound_measurement_item'
|
||||
AND column_name IN ('inbound_detail_id', 'external_detail_no')));
|
||||
|
||||
SELECT '第三方单条测量绑定唯一索引' AS check_item,
|
||||
COUNT(DISTINCT index_name) AS actual_value,
|
||||
IF(COUNT(DISTINCT index_name) = 1, 'PASS', 'FAIL') AS check_result
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_inbound_measurement_item'
|
||||
AND index_name = 'uk_measure_external_detail';
|
||||
|
||||
SELECT '二期入库新增表与字段脚本执行完成,请确认以上检查全部为 PASS' AS execution_message;
|
||||
|
||||
@@ -25,3 +25,10 @@
|
||||
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
|
||||
|
||||
执行结束后必须看到 `MIGRATION_COMPLETED`,并确认其前面的 IAM 权限、IAM 客户档案九字段、Warehouse 表结构、Order 幂等表和 Firstleg 索引核验均通过。
|
||||
|
||||
## 二期第三方测量增量
|
||||
|
||||
- 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。
|
||||
- 二期统一脚本:`2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` 已内联该增量。
|
||||
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
|
||||
- 本次代码回归不执行真实数据库迁移;测试环境部署 Firstleg 新版本前必须先执行该增量并确认末尾两项检查为 `PASS`。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.service.MeasurementImageService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Firstleg 保存第三方尺寸测量证据图片的内部入口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/file/v1/measurement-images")
|
||||
public class InternalMeasurementImageController {
|
||||
private final MeasurementImageService images;
|
||||
private final byte[] expectedToken;
|
||||
|
||||
public InternalMeasurementImageController(
|
||||
MeasurementImageService images,
|
||||
@Value("${baiyue.security.internal-service-token}") String token) {
|
||||
this.images = images;
|
||||
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<MeasurementImageService.UploadResult> upload(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
|
||||
}
|
||||
return ApiResponse.success(
|
||||
images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
|
||||
public ApiResponse<Void> delete(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@PathVariable long fileId) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0 || operatorId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单、文件和操作主体ID必须大于0");
|
||||
}
|
||||
images.deleteMeasurementEvidence(tenantId, inboundId, fileId, operatorId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,25 @@ public class MeasurementImageService {
|
||||
|
||||
@Transactional
|
||||
public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
|
||||
return recognize(storeObject(tenantId, inboundId, operatorId, file, "INBOUND_WEIGHT_IMAGE"));
|
||||
}
|
||||
|
||||
/** 保存第三方尺寸测量证据图片,不触发重量 OCR。 */
|
||||
@Transactional
|
||||
public UploadResult uploadMeasurementEvidence(long tenantId, long inboundId, long operatorId,
|
||||
MultipartFile file) {
|
||||
FileObjectEntity object = storeObject(
|
||||
tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE");
|
||||
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
|
||||
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null);
|
||||
}
|
||||
|
||||
private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
|
||||
MultipartFile file, String businessType) {
|
||||
var stored = storage.store(tenantId, inboundId, file);
|
||||
FileObjectEntity object = new FileObjectEntity();
|
||||
object.setTenantId(tenantId);
|
||||
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
|
||||
object.setBusinessType(businessType);
|
||||
object.setBusinessId(Long.toString(inboundId));
|
||||
object.setOriginalName(stored.originalName());
|
||||
object.setObjectKey(stored.objectKey());
|
||||
@@ -56,12 +71,15 @@ public class MeasurementImageService {
|
||||
object.setDeleted(false);
|
||||
object.setVersion(0);
|
||||
files.insert(object);
|
||||
|
||||
return recognize(object);
|
||||
return object;
|
||||
}
|
||||
|
||||
public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) {
|
||||
return recognize(requireObject(tenantId, inboundId, fileId));
|
||||
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
|
||||
if (!"INBOUND_WEIGHT_IMAGE".equals(object.getBusinessType())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "尺寸测量证据图片不执行重量OCR");
|
||||
}
|
||||
return recognize(object);
|
||||
}
|
||||
|
||||
public Download download(long tenantId, long inboundId, long fileId) {
|
||||
@@ -69,11 +87,25 @@ public class MeasurementImageService {
|
||||
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
|
||||
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
|
||||
if (!"INBOUND_MEASUREMENT_IMAGE".equals(object.getBusinessType())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仅允许清理第三方尺寸测量证据图片");
|
||||
}
|
||||
object.setDeleted(true);
|
||||
object.setStatus("DELETED");
|
||||
object.setUpdatedBy(operatorId);
|
||||
object.setUpdatedAt(LocalDateTime.now());
|
||||
files.updateById(object);
|
||||
storage.delete(object.getObjectKey());
|
||||
}
|
||||
|
||||
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
|
||||
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
|
||||
.eq(FileObjectEntity::getTenantId, tenantId)
|
||||
.eq(FileObjectEntity::getId, fileId)
|
||||
.eq(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE")
|
||||
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
|
||||
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
|
||||
.eq(FileObjectEntity::getDeleted, false));
|
||||
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
|
||||
|
||||
@@ -56,6 +56,16 @@ public class MeasurementImageStorage {
|
||||
catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "称重图片读取失败"); }
|
||||
}
|
||||
|
||||
public void delete(String objectKey) {
|
||||
Path path = root.resolve(objectKey).normalize();
|
||||
if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "测量图片清理失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyImage(MultipartFile file) {
|
||||
try (InputStream input = file.getInputStream()) {
|
||||
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
|
||||
|
||||
+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 jakarta.validation.constraints.Pattern;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 第三方设备长宽高测量开放接口。 */
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/openapi/v1/inbound-measurements")
|
||||
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码接收长宽高结果,只落测量数据,不审核、计费或入库")
|
||||
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码和订单货物明细接收一条箱规及一张图片,只落测量数据,不审核、计费或入库")
|
||||
@SecurityRequirement(name = "measurementAppKey")
|
||||
@SecurityRequirement(name = "measurementTimestamp")
|
||||
@SecurityRequirement(name = "measurementNonce")
|
||||
@@ -36,25 +38,25 @@ public class OpenApiInboundMeasurementController {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Operation(summary = "提交第三方长宽高测量结果",
|
||||
description = "签名正文为 METHOD、PATH、时间戳、Nonce、Idempotency-Key、原始 body SHA-256 六行;只保存调用日志、测量批次和明细")
|
||||
@Operation(summary = "提交一条第三方箱规测量结果和图片",
|
||||
description = "multipart 包含 data 原始JSON和 image 图片;签名 body 为 data原文、换行、图片SHA-256,外层仍按 METHOD、PATH、时间戳、Nonce、Idempotency-Key、body SHA-256 六行签名")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功或业务失败,按响应体 code 判断"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "413", description = "请求体超过网关限制"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
|
||||
})
|
||||
@PostMapping
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<EventResponse> receive(
|
||||
@RequestHeader("X-App-Key") String appKey,
|
||||
@RequestHeader("X-Timestamp") String timestamp,
|
||||
@RequestHeader("X-Nonce") String nonce,
|
||||
@RequestHeader("X-Signature") String signature,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
|
||||
description = "原始 UTF-8 JSON;任何空白差异都会改变签名")
|
||||
@RequestBody String rawBody) {
|
||||
@RequestPart("data") String rawData,
|
||||
@RequestPart("image") MultipartFile image) {
|
||||
return ApiResponse.success(service.receive(
|
||||
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey), rawBody));
|
||||
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey),
|
||||
rawData, image));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询第三方测量事件状态",
|
||||
|
||||
@@ -1,65 +1,71 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 第三方长宽高测量开放接口 DTO。 */
|
||||
public final class OpenApiMeasurementDtos {
|
||||
private OpenApiMeasurementDtos() {
|
||||
}
|
||||
|
||||
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码提交的第三方长宽高测量结果")
|
||||
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码和订单货物明细提交一条箱规测量结果")
|
||||
public record CallbackRequest(
|
||||
@NotBlank @Size(max = 128)
|
||||
@Pattern(regexp = "[A-Za-z0-9._:-]+")
|
||||
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
|
||||
String eventNo,
|
||||
@NotBlank @Size(max = 128)
|
||||
@Pattern(regexp = "[A-Za-z0-9._:-]+")
|
||||
@Schema(description = "第三方测量明细唯一号,用于防止同一物理明细重复绑定", example = "BOX-20260801-0001")
|
||||
String externalDetailNo,
|
||||
@NotBlank @Size(max = 50)
|
||||
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
|
||||
String warehouseCode,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
|
||||
String inboundCode,
|
||||
@NotNull @Min(1)
|
||||
@Schema(description = "由柏悦订单明细接口提供的货物明细ID", example = "190000000000000001")
|
||||
Long inboundDetailId,
|
||||
@NotBlank @Size(max = 64)
|
||||
@Schema(description = "测量设备编码", example = "DEVICE-01")
|
||||
String deviceCode,
|
||||
@NotNull
|
||||
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
|
||||
OffsetDateTime measuredAt,
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items) {
|
||||
}
|
||||
|
||||
@Schema(name = "InboundMeasurementItem", description = "相同箱规的测量明细")
|
||||
public record Item(
|
||||
@NotBlank @Size(max = 32)
|
||||
@Pattern(regexp = "[A-Za-z0-9._:-]+") String itemNo,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal lengthCm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
|
||||
@NotNull @Min(1) @Max(1000000) Integer quantity,
|
||||
@Size(max = 2048) String sourceImageUrl,
|
||||
@NotBlank @Size(max = 64) String boxSpecification) {
|
||||
@NotNull @Min(1) @Max(1000000) Integer packageCount,
|
||||
@DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal unitWeightKg,
|
||||
@NotBlank @Size(max = 64) String boxSpecification,
|
||||
@Size(max = 500) String remark) {
|
||||
}
|
||||
|
||||
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
|
||||
public record EventResponse(
|
||||
String eventNo,
|
||||
String inboundCode,
|
||||
Long inboundDetailId,
|
||||
Long measurementId,
|
||||
Long measurementItemId,
|
||||
Long imageFileId,
|
||||
boolean accepted,
|
||||
String status,
|
||||
OffsetDateTime receivedAt) {
|
||||
public EventResponse(String eventNo, String inboundCode, boolean accepted,
|
||||
String status, OffsetDateTime receivedAt) {
|
||||
this(eventNo, inboundCode, null, null, null, null, accepted, status, receivedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public class InboundMeasurementEntity {
|
||||
private String sourceType;
|
||||
private String sourceCode;
|
||||
private String externalEventNo;
|
||||
private String externalRequestHash;
|
||||
private String deviceCode;
|
||||
private String warehouseCode;
|
||||
private LocalDateTime measuredAt;
|
||||
|
||||
@@ -18,6 +18,8 @@ public class InboundMeasurementItemEntity {
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long measurementId;
|
||||
private Long inboundDetailId;
|
||||
private String externalDetailNo;
|
||||
private String itemNo;
|
||||
private String boxSpecification;
|
||||
private Integer packageCount;
|
||||
|
||||
@@ -14,6 +14,7 @@ public class InboundOrderLookupEntity {
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String inboundCode;
|
||||
private Long orderId;
|
||||
private Long warehouseId;
|
||||
private String processStatus;
|
||||
private Integer deleted;
|
||||
|
||||
+15
@@ -53,4 +53,19 @@ public class InboundMeasurementOpenApiRepository {
|
||||
items.insert(item);
|
||||
}
|
||||
|
||||
public InboundMeasurementItemEntity findSingleItem(long tenantId, long measurementId) {
|
||||
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public InboundMeasurementItemEntity findExternalDetail(long tenantId, long inboundDetailId,
|
||||
String externalDetailNo) {
|
||||
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementItemEntity::getInboundDetailId, inboundDetailId)
|
||||
.eq(InboundMeasurementItemEntity::getExternalDetailNo, externalDetailNo));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+198
-68
@@ -1,11 +1,13 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.MeasurementImageClient;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.CallbackRequest;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.Item;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||
@@ -15,63 +17,87 @@ import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.Validator;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 第三方测量接收编排。只写调用日志、测量批次和明细,不提交审核且不更新入库主单。
|
||||
* 第三方单条测量接收编排:按入仓码定位主单、按订单货物明细ID绑定来源、
|
||||
* 将同一请求携带的图片保存到文件中心,再落一条测量批次和一条箱规明细。
|
||||
* 本服务不提交审核、不计费且不更新入库主单。
|
||||
*/
|
||||
@Service
|
||||
public class InboundMeasurementOpenApiService {
|
||||
private static final Logger log = LoggerFactory.getLogger(InboundMeasurementOpenApiService.class);
|
||||
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
|
||||
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
|
||||
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
|
||||
private static final String CALCULATION_RULE_VERSION = "V1_CEILING_4";
|
||||
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
||||
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
|
||||
|
||||
private final InboundMeasurementOpenApiRepository repository;
|
||||
private final MeasurementRequestAuthenticator authenticator;
|
||||
private final ThirdPartyCallLogService callLogService;
|
||||
private final OrderCargoClient orderCargoClient;
|
||||
private final MeasurementImageClient imageClient;
|
||||
private final String internalToken;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Validator validator;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public InboundMeasurementOpenApiService(InboundMeasurementOpenApiRepository repository,
|
||||
MeasurementRequestAuthenticator authenticator,
|
||||
ThirdPartyCallLogService callLogService,
|
||||
ObjectMapper objectMapper, Validator validator,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
public InboundMeasurementOpenApiService(
|
||||
InboundMeasurementOpenApiRepository repository,
|
||||
MeasurementRequestAuthenticator authenticator,
|
||||
ThirdPartyCallLogService callLogService,
|
||||
OrderCargoClient orderCargoClient,
|
||||
MeasurementImageClient imageClient,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
||||
ObjectMapper objectMapper,
|
||||
Validator validator,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
this.repository = repository;
|
||||
this.authenticator = authenticator;
|
||||
this.callLogService = callLogService;
|
||||
this.orderCargoClient = orderCargoClient;
|
||||
this.imageClient = imageClient;
|
||||
this.internalToken = internalToken;
|
||||
this.objectMapper = objectMapper;
|
||||
this.validator = validator;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
}
|
||||
|
||||
public EventResponse receive(MeasurementRequestHeaders headers, String rawBody) {
|
||||
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawBody);
|
||||
public EventResponse receive(MeasurementRequestHeaders headers, String rawData, MultipartFile image) {
|
||||
requireImage(image);
|
||||
String signedPayload = signedPayload(rawData, image);
|
||||
String requestHash = MeasurementRequestAuthenticator.sha256Hex(signedPayload);
|
||||
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawData, requestHash);
|
||||
try {
|
||||
AuthenticatedMeasurementApp app = authenticator.authenticate(
|
||||
"POST", CALLBACK_PATH, headers, rawBody, true);
|
||||
CallbackRequest request = parseAndValidate(rawBody);
|
||||
String requestHash = MeasurementRequestAuthenticator.sha256Hex(rawBody);
|
||||
"POST", CALLBACK_PATH, headers, signedPayload, true);
|
||||
CallbackRequest request = parseAndValidate(rawData);
|
||||
if (!callLogService.claimIdempotency(logId)) {
|
||||
EventResponse replay = replayIdempotency(app, headers.idempotencyKey(), requestHash);
|
||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "duplicate", writeJson(replay));
|
||||
return replay;
|
||||
}
|
||||
EventResponse result = transactionTemplate.execute(status ->
|
||||
persist(app, request, rawBody, requestHash));
|
||||
String resultJson = writeJson(result);
|
||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", resultJson);
|
||||
persist(app, request, rawData, requestHash, image));
|
||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
|
||||
return result;
|
||||
} catch (RuntimeException exception) {
|
||||
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
|
||||
@@ -86,14 +112,11 @@ public class InboundMeasurementOpenApiService {
|
||||
try {
|
||||
AuthenticatedMeasurementApp app = authenticator.authenticate("GET", path, headers, "", false);
|
||||
InboundMeasurementEntity measurement = repository.findEvent(app.tenantId(), app.appKey(), eventNo);
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
|
||||
}
|
||||
if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
|
||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), measurement.getInboundId());
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
|
||||
}
|
||||
EventResponse result = response(measurement, inbound.getInboundCode());
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
|
||||
InboundMeasurementItemEntity item = repository.findSingleItem(app.tenantId(), measurement.getId());
|
||||
EventResponse result = response(measurement, inbound.getInboundCode(), item);
|
||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
|
||||
return result;
|
||||
} catch (RuntimeException exception) {
|
||||
@@ -103,23 +126,20 @@ public class InboundMeasurementOpenApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private EventResponse persist(AuthenticatedMeasurementApp app,
|
||||
CallbackRequest request, String rawBody, String requestHash) {
|
||||
InboundMeasurementEntity existingEvent = repository.findEvent(
|
||||
app.tenantId(), app.appKey(), request.eventNo());
|
||||
if (existingEvent != null) {
|
||||
if (!MeasurementRequestAuthenticator.sha256Hex(existingEvent.getRawPayloadJson()).equals(requestHash)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的报文内容不一致");
|
||||
private EventResponse persist(AuthenticatedMeasurementApp app, CallbackRequest request,
|
||||
String rawData, String requestHash, MultipartFile image) {
|
||||
InboundMeasurementEntity existing = repository.findEvent(app.tenantId(), app.appKey(), request.eventNo());
|
||||
if (existing != null) {
|
||||
if (!requestHash.equals(existing.getExternalRequestHash())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的测量数据或图片不一致");
|
||||
}
|
||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existingEvent.getInboundId());
|
||||
EventResponse result = response(existingEvent, inbound == null ? request.inboundCode() : inbound.getInboundCode());
|
||||
return result;
|
||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existing.getInboundId());
|
||||
return response(existing, inbound == null ? request.inboundCode() : inbound.getInboundCode(),
|
||||
repository.findSingleItem(app.tenantId(), existing.getId()));
|
||||
}
|
||||
|
||||
Long boundWarehouseId = app.warehouseBindings().get(request.warehouseCode());
|
||||
if (boundWarehouseId == null) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
|
||||
}
|
||||
if (boundWarehouseId == null) throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
|
||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), request.inboundCode());
|
||||
if (inbound == null || !boundWarehouseId.equals(inbound.getWarehouseId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入仓码不存在或不属于绑定仓库");
|
||||
@@ -128,17 +148,80 @@ public class InboundMeasurementOpenApiService {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许接收测量结果");
|
||||
}
|
||||
|
||||
InboundMeasurementEntity measurement = buildMeasurement(app, request, rawBody, inbound.getId());
|
||||
repository.insertMeasurement(measurement);
|
||||
for (Item item : request.items()) {
|
||||
repository.insertItem(buildItem(app.tenantId(), measurement.getId(), item));
|
||||
OrderCargoClient.CargoBinding cargo = requireCargo(app.tenantId(), inbound, request.inboundDetailId());
|
||||
if (repository.findExternalDetail(app.tenantId(), cargo.cargoId(), request.externalDetailNo()) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "该订单明细已绑定相同第三方测量明细号");
|
||||
}
|
||||
EventResponse result = response(measurement, request.inboundCode());
|
||||
return result;
|
||||
MeasurementImageClient.UploadResult uploaded = uploadImage(app, inbound, image);
|
||||
InboundMeasurementEntity measurement = buildMeasurement(app, request, rawData, requestHash, inbound.getId());
|
||||
InboundMeasurementItemEntity item = buildItem(app.tenantId(), measurement.getId(), request, cargo,
|
||||
uploaded.fileId());
|
||||
try {
|
||||
repository.insertMeasurement(measurement);
|
||||
repository.insertItem(item);
|
||||
} catch (RuntimeException exception) {
|
||||
cleanupImage(app, inbound, uploaded.fileId());
|
||||
throw exception;
|
||||
}
|
||||
return response(measurement, request.inboundCode(), item);
|
||||
}
|
||||
|
||||
private void cleanupImage(AuthenticatedMeasurementApp app, InboundOrderLookupEntity inbound, long fileId) {
|
||||
try {
|
||||
imageClient.delete(internalToken, app.subjectId(), app.tenantId(), inbound.getId(), fileId);
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
log.warn("第三方测量保存失败后的图片补偿清理失败 tenantId={}, inboundId={}, fileId={}",
|
||||
app.tenantId(), inbound.getId(), fileId, cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private OrderCargoClient.CargoBinding requireCargo(long tenantId, InboundOrderLookupEntity inbound,
|
||||
long cargoId) {
|
||||
if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效订单");
|
||||
}
|
||||
try {
|
||||
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(
|
||||
internalToken, tenantId, inbound.getOrderId(), cargoId);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw downstream(response, "订单货物明细校验失败");
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法校验入仓明细");
|
||||
}
|
||||
}
|
||||
|
||||
private MeasurementImageClient.UploadResult uploadImage(AuthenticatedMeasurementApp app,
|
||||
InboundOrderLookupEntity inbound,
|
||||
MultipartFile image) {
|
||||
try {
|
||||
ApiResponse<MeasurementImageClient.UploadResult> response = imageClient.upload(
|
||||
internalToken, app.subjectId(), app.tenantId(), inbound.getId(), image);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().fileId() == null) {
|
||||
throw downstream(response, "测量图片保存失败");
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,测量数据未保存");
|
||||
}
|
||||
}
|
||||
|
||||
private BizException downstream(ApiResponse<?> response, String fallback) {
|
||||
if (response == null) return new BizException(ResultCode.SERVICE_UNAVAILABLE, fallback);
|
||||
String message = response.message() == null || response.message().isBlank() ? fallback : response.message();
|
||||
return new BizException(ResultCode.of(response.code()), message);
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity buildMeasurement(AuthenticatedMeasurementApp app,
|
||||
CallbackRequest request, String rawBody, long inboundId) {
|
||||
CallbackRequest request, String rawData,
|
||||
String requestHash, long inboundId) {
|
||||
BigDecimal unitRaw = unitRawVolume(request);
|
||||
BigDecimal totalRaw = unitRaw.multiply(BigDecimal.valueOf(request.packageCount()))
|
||||
.setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal totalVolume = unitRaw.setScale(4, RoundingMode.CEILING)
|
||||
.multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.CEILING);
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(IdWorker.getId());
|
||||
measurement.setTenantId(app.tenantId());
|
||||
@@ -146,45 +229,65 @@ public class InboundMeasurementOpenApiService {
|
||||
measurement.setSourceType("THIRD_PARTY");
|
||||
measurement.setSourceCode(app.appKey());
|
||||
measurement.setExternalEventNo(request.eventNo());
|
||||
measurement.setExternalRequestHash(requestHash);
|
||||
measurement.setDeviceCode(request.deviceCode());
|
||||
measurement.setWarehouseCode(request.warehouseCode());
|
||||
measurement.setMeasuredAt(request.measuredAt().withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime());
|
||||
measurement.setStatus("RECEIVED");
|
||||
measurement.setRawPayloadJson(rawBody);
|
||||
measurement.setTotalPackageCount(request.items().stream().mapToInt(Item::quantity).sum());
|
||||
BigDecimal rawTotal = request.items().stream().map(this::rawVolume).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
measurement.setTotalVolumeRawCbm(rawTotal.setScale(6, RoundingMode.HALF_UP));
|
||||
measurement.setTotalVolumeCbm(rawTotal.setScale(4, RoundingMode.CEILING));
|
||||
measurement.setRawPayloadJson(rawData);
|
||||
measurement.setTotalPackageCount(request.packageCount());
|
||||
measurement.setTotalVolumeRawCbm(totalRaw);
|
||||
measurement.setTotalVolumeCbm(totalVolume);
|
||||
measurement.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||
measurement.setCreatedBy(app.subjectId());
|
||||
measurement.setCreatedAt(LocalDateTime.now());
|
||||
measurement.setUpdatedAt(LocalDateTime.now());
|
||||
measurement.setVersion(0);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId, Item source) {
|
||||
BigDecimal rawVolume = rawVolume(source);
|
||||
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId,
|
||||
CallbackRequest source,
|
||||
OrderCargoClient.CargoBinding cargo,
|
||||
long imageFileId) {
|
||||
BigDecimal unitRaw = unitRawVolume(source);
|
||||
BigDecimal unitVolume = unitRaw.setScale(4, RoundingMode.CEILING);
|
||||
BigDecimal count = BigDecimal.valueOf(source.packageCount());
|
||||
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.CEILING);
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setId(IdWorker.getId());
|
||||
item.setTenantId(tenantId);
|
||||
item.setMeasurementId(measurementId);
|
||||
item.setItemNo(source.itemNo());
|
||||
item.setBoxSpecification(org.springframework.util.StringUtils.hasText(source.boxSpecification())
|
||||
? source.boxSpecification().trim() : source.itemNo());
|
||||
item.setPackageCount(source.quantity());
|
||||
item.setInboundDetailId(cargo.cargoId());
|
||||
item.setExternalDetailNo(source.externalDetailNo());
|
||||
item.setItemNo(Integer.toString(cargo.itemNo()));
|
||||
item.setBoxSpecification(source.boxSpecification().trim());
|
||||
item.setPackageCount(source.packageCount());
|
||||
item.setLengthCm(source.lengthCm());
|
||||
item.setWidthCm(source.widthCm());
|
||||
item.setHeightCm(source.heightCm());
|
||||
item.setVolumeRawCbm(rawVolume.setScale(6, RoundingMode.HALF_UP));
|
||||
item.setVolumeCbm(rawVolume.setScale(4, RoundingMode.CEILING));
|
||||
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
|
||||
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
|
||||
item.setUnitWeightKg(source.unitWeightKg() == null ? null
|
||||
: source.unitWeightKg().setScale(3, RoundingMode.CEILING));
|
||||
item.setUnitVolumeCbm(unitVolume);
|
||||
item.setVolumeRawCbm(totalRaw);
|
||||
item.setVolumeCbm(totalVolume);
|
||||
item.setTotalWeightKg(source.unitWeightKg() == null ? null
|
||||
: source.unitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
|
||||
item.setTotalVolumeCbm(totalVolume);
|
||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
|
||||
item.setSourceImageFileId(imageFileId);
|
||||
item.setRemark(source.remark());
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
return item;
|
||||
}
|
||||
|
||||
private BigDecimal rawVolume(Item item) {
|
||||
return item.lengthCm().multiply(item.widthCm()).multiply(item.heightCm())
|
||||
.multiply(BigDecimal.valueOf(item.quantity()))
|
||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 12, RoundingMode.HALF_UP);
|
||||
private BigDecimal unitRawVolume(CallbackRequest request) {
|
||||
return request.lengthCm().multiply(request.widthCm()).multiply(request.heightCm())
|
||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private EventResponse replayIdempotency(AuthenticatedMeasurementApp app, String key, String requestHash) {
|
||||
@@ -193,7 +296,7 @@ public class InboundMeasurementOpenApiService {
|
||||
throw new BizException(ResultCode.CONFLICT, "相同 Idempotency-Key 的请求正在处理");
|
||||
}
|
||||
if (!requestHash.equals(existing.getRequestHash())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 的报文内容不一致");
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 的测量数据或图片不一致");
|
||||
}
|
||||
if (!"ACCEPTED".equals(existing.getProcessingStatus()) || existing.getResultJson() == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 已处理但未成功");
|
||||
@@ -205,25 +308,52 @@ public class InboundMeasurementOpenApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private CallbackRequest parseAndValidate(String rawBody) {
|
||||
private CallbackRequest parseAndValidate(String rawData) {
|
||||
try {
|
||||
CallbackRequest request = objectMapper.readValue(rawBody, CallbackRequest.class);
|
||||
CallbackRequest request = objectMapper.readValue(rawData, CallbackRequest.class);
|
||||
Set<ConstraintViolation<CallbackRequest>> violations = validator.validate(request);
|
||||
if (!violations.isEmpty()) {
|
||||
ConstraintViolation<CallbackRequest> violation = violations.iterator().next();
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
violations.iterator().next().getPropertyPath() + " " + violations.iterator().next().getMessage());
|
||||
violation.getPropertyPath() + " " + violation.getMessage());
|
||||
}
|
||||
return request;
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "请求 JSON 格式无效");
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "data 请求部分不是有效 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
private EventResponse response(InboundMeasurementEntity measurement, String inboundCode) {
|
||||
return new EventResponse(measurement.getExternalEventNo(), inboundCode, true,
|
||||
measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC));
|
||||
private void requireImage(MultipartFile image) {
|
||||
if (image == null || image.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "测量图片不能为空");
|
||||
if (image.getSize() > 5L * 1024 * 1024) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "测量图片不能超过5MB");
|
||||
}
|
||||
}
|
||||
|
||||
private String signedPayload(String rawData, MultipartFile image) {
|
||||
try {
|
||||
return (rawData == null ? "" : rawData) + "\n" + sha256Hex(image.getBytes());
|
||||
} catch (IOException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "测量图片无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
private String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("SHA-256 不可用", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private EventResponse response(InboundMeasurementEntity measurement, String inboundCode,
|
||||
InboundMeasurementItemEntity item) {
|
||||
return new EventResponse(measurement.getExternalEventNo(), inboundCode,
|
||||
item == null ? null : item.getInboundDetailId(), measurement.getId(),
|
||||
item == null ? null : item.getId(), item == null ? null : item.getSourceImageFileId(),
|
||||
true, measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
|
||||
@@ -28,6 +28,13 @@ public class ThirdPartyCallLogService {
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody) {
|
||||
return start(method, path, headers, rawBody,
|
||||
MeasurementRequestAuthenticator.sha256Hex(rawBody));
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody,
|
||||
String requestHash) {
|
||||
ThirdPartyCallLogEntity log = new ThirdPartyCallLogEntity();
|
||||
log.setId(IdWorker.getId());
|
||||
MeasurementOpenApiProperties.AppCredential app = properties.getApps().get(headers.appKey());
|
||||
@@ -40,7 +47,7 @@ public class ThirdPartyCallLogService {
|
||||
log.setNonce(limit(headers.nonce(), 128));
|
||||
log.setIdempotencyKey(limit(headers.idempotencyKey(), 128));
|
||||
log.setSignatureValue(limit(headers.signature(), 128));
|
||||
log.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(rawBody));
|
||||
log.setRequestHash(requestHash);
|
||||
log.setRawPayload(rawBody == null ? "" : rawBody);
|
||||
log.setProcessingStatus("PROCESSING");
|
||||
log.setRequestedAt(LocalDateTime.now());
|
||||
|
||||
@@ -20,9 +20,19 @@ spring:
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
config.namespace: ${NACOS_NAMESPACE:public}
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 5MB
|
||||
max-request-size: 6MB
|
||||
baiyue:
|
||||
security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||
clients:
|
||||
order:
|
||||
url: ${ORDER_SERVICE_URL:}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:}
|
||||
firstleg:
|
||||
inbound:
|
||||
allow-rejected-measurement: false
|
||||
|
||||
@@ -146,6 +146,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
|
||||
source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
|
||||
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
|
||||
external_event_no VARCHAR(128) NULL COMMENT '第三方事件号',
|
||||
external_request_hash CHAR(64) NULL COMMENT '第三方data原文和图片内容联合请求哈希',
|
||||
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
|
||||
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
|
||||
measured_at DATETIME(3) NULL COMMENT '设备测量时间',
|
||||
@@ -173,6 +174,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空',
|
||||
external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空',
|
||||
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
|
||||
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
|
||||
@@ -197,6 +200,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
|
||||
UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no),
|
||||
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
|
||||
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 二期第三方单条箱规 + 单张图片绑定增量;可重复执行。
|
||||
USE baiyue_firstleg;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase2_add_measurement_column(
|
||||
IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column
|
||||
) THEN
|
||||
SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
|
||||
PREPARE statement FROM @ddl;
|
||||
EXECUTE statement;
|
||||
DEALLOCATE PREPARE statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL phase2_add_measurement_column(
|
||||
'fl_inbound_measurement', 'external_request_hash',
|
||||
'CHAR(64) NULL COMMENT ''第三方data原文和图片内容联合请求哈希'' AFTER external_event_no');
|
||||
CALL phase2_add_measurement_column(
|
||||
'fl_inbound_measurement_item', 'inbound_detail_id',
|
||||
'BIGINT UNSIGNED NULL COMMENT ''绑定的订单货物明细ID,平台手工明细可为空'' AFTER measurement_id');
|
||||
CALL phase2_add_measurement_column(
|
||||
'fl_inbound_measurement_item', 'external_detail_no',
|
||||
'VARCHAR(128) NULL COMMENT ''第三方测量明细唯一号,平台手工明细可为空'' AFTER inbound_detail_id');
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase2_add_measurement_column;
|
||||
|
||||
SET @has_index = (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'fl_inbound_measurement_item'
|
||||
AND INDEX_NAME = 'uk_measure_external_detail'
|
||||
);
|
||||
SET @ddl = IF(
|
||||
@has_index = 0,
|
||||
'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE statement FROM @ddl;
|
||||
EXECUTE statement;
|
||||
DEALLOCATE PREPARE statement;
|
||||
@@ -69,12 +69,13 @@ paths:
|
||||
'403': {description: 无分配权限}
|
||||
/openapi/v1/inbound-measurements:
|
||||
post:
|
||||
summary: 提交第三方长宽高测量结果
|
||||
summary: 提交一条第三方箱规测量结果和一张图片
|
||||
description: >-
|
||||
只写 fl_third_party_call_log、fl_inbound_measurement、fl_inbound_measurement_item
|
||||
和 HTTP 幂等结果,不审核、不计费、不入库,也不改变入库单状态。
|
||||
data 中的 inboundCode 匹配入库单,inboundDetailId 必须匹配该入库单关联订单中的已有货物明细;
|
||||
每次只保存一条箱规明细和一张图片,不审核、不计费、不入库,也不改变入库单状态。
|
||||
签名串依次为 METHOD、原始 PATH、X-Timestamp、X-Nonce、Idempotency-Key、
|
||||
原始 UTF-8 body 的 SHA-256 小写十六进制,六部分使用 LF(\n)连接且末尾不追加换行。
|
||||
签名body的 SHA-256 小写十六进制,六部分使用 LF(\n)连接且末尾不追加换行。
|
||||
签名body = data原始UTF-8文本 + LF + image文件内容SHA-256小写十六进制。
|
||||
三层语义:Nonce 防短时重放;Idempotency-Key 标识一次 HTTP 写操作;
|
||||
appKey + eventNo 标识第三方业务事件。
|
||||
security:
|
||||
@@ -89,8 +90,18 @@ paths:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InboundMeasurementCallbackRequest'}
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [data, image]
|
||||
properties:
|
||||
data:
|
||||
type: string
|
||||
description: InboundMeasurementCallbackRequest 的原始UTF-8 JSON文本
|
||||
image:
|
||||
type: string
|
||||
format: binary
|
||||
description: 当前测量明细唯一对应图片,JPG/PNG/BMP且不超过5MB
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体字符串 code 判断;非法签名返回 A0401}
|
||||
'413': {description: 请求体超过网关限制}
|
||||
@@ -242,34 +253,31 @@ components:
|
||||
schemas:
|
||||
InboundMeasurementCallbackRequest:
|
||||
type: object
|
||||
required: [eventNo, warehouseCode, inboundCode, deviceCode, measuredAt, items]
|
||||
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
|
||||
properties:
|
||||
eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001}
|
||||
externalDetailNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: BOX-20260801-0001}
|
||||
warehouseCode: {type: string, maxLength: 50, example: CN-YW-01}
|
||||
inboundCode: {type: string, maxLength: 40, example: RK202608010001}
|
||||
inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID}
|
||||
deviceCode: {type: string, maxLength: 64, example: DEVICE-01}
|
||||
measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'}
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
items: {$ref: '#/components/schemas/InboundMeasurementItem'}
|
||||
InboundMeasurementItem:
|
||||
type: object
|
||||
required: [itemNo, boxSpecification, lengthCm, widthCm, heightCm, quantity]
|
||||
properties:
|
||||
itemNo: {type: string, maxLength: 32, example: '001'}
|
||||
boxSpecification: {type: string, maxLength: 64, example: '61.5x42x42cm', description: 入库单绑定的箱规明细}
|
||||
boxSpecification: {type: string, maxLength: 64, example: '40x30x47cm'}
|
||||
lengthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 50.20}
|
||||
widthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 40.10}
|
||||
heightCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 30.00}
|
||||
quantity: {type: integer, minimum: 1, maximum: 1000000, example: 5}
|
||||
sourceImageUrl: {type: string, maxLength: 2048, nullable: true, description: 仅随原始报文留档,不直接写文件中心}
|
||||
packageCount: {type: integer, minimum: 1, maximum: 1000000, example: 50}
|
||||
unitWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, example: 20}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
InboundMeasurementEventResponse:
|
||||
type: object
|
||||
properties:
|
||||
eventNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
inboundDetailId: {type: string}
|
||||
measurementId: {type: string}
|
||||
measurementItemId: {type: string}
|
||||
imageFileId: {type: string}
|
||||
accepted: {type: boolean}
|
||||
status: {type: string, enum: [RECEIVED]}
|
||||
receivedAt: {type: string, format: date-time}
|
||||
|
||||
+10
-5
@@ -3,7 +3,8 @@ package com.baiyue.firstleg.controller;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -13,12 +14,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@DisplayName("第三方测量开放接口契约")
|
||||
class OpenApiInboundMeasurementControllerContractTest {
|
||||
@Test
|
||||
@DisplayName("回调以原始字符串接收报文,保证签名正文不被重新序列化")
|
||||
void shouldReceiveRawRequestBody() throws Exception {
|
||||
@DisplayName("回调接收原始data和唯一图片,保证签名与一对一绑定")
|
||||
void shouldReceiveRawDataAndImage() throws Exception {
|
||||
Method method = OpenApiInboundMeasurementController.class.getDeclaredMethod("receive",
|
||||
String.class, String.class, String.class, String.class, String.class, String.class);
|
||||
assertThat(method.getParameters()[5].getAnnotation(RequestBody.class)).isNotNull();
|
||||
String.class, String.class, String.class, String.class, String.class,
|
||||
String.class, MultipartFile.class);
|
||||
assertThat(method.getParameters()[5].getAnnotation(RequestPart.class).value()).isEqualTo("data");
|
||||
assertThat(method.getParameters()[5].getType()).isEqualTo(String.class);
|
||||
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class).value()).isEqualTo("image");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -33,6 +36,8 @@ class OpenApiInboundMeasurementControllerContractTest {
|
||||
.contains("Idempotency-Key")
|
||||
.contains("appKey + eventNo")
|
||||
.contains("Nonce")
|
||||
.contains("inboundDetailId")
|
||||
.contains("multipart/form-data")
|
||||
.contains("不改变入库单状态");
|
||||
}
|
||||
}
|
||||
|
||||
+89
-10
@@ -1,7 +1,11 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.client.MeasurementImageClient;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository;
|
||||
@@ -15,12 +19,15 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionCallback;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -30,15 +37,19 @@ import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
@DisplayName("第三方测量开放接口服务")
|
||||
class InboundMeasurementOpenApiServiceTest {
|
||||
private static final String BODY = """
|
||||
{"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001",
|
||||
"deviceCode":"DEVICE-01","measuredAt":"2026-08-01T10:30:00+08:00",
|
||||
"items":[{"itemNo":"001","lengthCm":50.20,"widthCm":40.10,"heightCm":30.00,
|
||||
"quantity":5,"boxSpecification":"50.2x40.1x30cm"}]}
|
||||
"externalDetailNo":"BOX-001","inboundDetailId":"80001","deviceCode":"DEVICE-01",
|
||||
"measuredAt":"2026-08-01T10:30:00+08:00","lengthCm":50.20,"widthCm":40.10,
|
||||
"heightCm":30.00,"packageCount":5,"unitWeightKg":12.5,
|
||||
"boxSpecification":"50.2x40.1x30cm"}
|
||||
""";
|
||||
private static final byte[] IMAGE = "image-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
@DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口")
|
||||
@@ -47,12 +58,23 @@ class InboundMeasurementOpenApiServiceTest {
|
||||
InboundOrderLookupEntity inbound = inbound();
|
||||
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound);
|
||||
|
||||
EventResponse response = fixture.service.receive(headers("nonce-1"), BODY);
|
||||
EventResponse response = fixture.service.receive(headers("nonce-1"), BODY, image());
|
||||
|
||||
assertThat(response.accepted()).isTrue();
|
||||
assertThat(response.status()).isEqualTo("RECEIVED");
|
||||
verify(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class));
|
||||
verify(fixture.repository).insertItem(any());
|
||||
ArgumentCaptor<InboundMeasurementItemEntity> itemCaptor =
|
||||
ArgumentCaptor.forClass(InboundMeasurementItemEntity.class);
|
||||
verify(fixture.repository).insertItem(itemCaptor.capture());
|
||||
assertThat(itemCaptor.getValue().getInboundDetailId()).isEqualTo(80001L);
|
||||
assertThat(itemCaptor.getValue().getExternalDetailNo()).isEqualTo("BOX-001");
|
||||
assertThat(itemCaptor.getValue().getSourceImageFileId()).isEqualTo(99001L);
|
||||
assertThat(itemCaptor.getValue().getItemNo()).isEqualTo("1");
|
||||
assertThat(itemCaptor.getValue().getTotalVolumeCbm()).isEqualByComparingTo("0.3020");
|
||||
verify(fixture.orderCargo).detail("internal-token", 10001L, 60001L, 80001L);
|
||||
verify(fixture.images).upload(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.eq(90001L), org.mockito.ArgumentMatchers.eq(10001L),
|
||||
org.mockito.ArgumentMatchers.eq(70001L), any());
|
||||
assertThatNoException().isThrownBy(() ->
|
||||
InboundMeasurementOpenApiRepository.class.getDeclaredMethod("findInbound", long.class, String.class));
|
||||
assertThat(InboundMeasurementOpenApiRepository.class.getDeclaredMethods())
|
||||
@@ -67,38 +89,82 @@ class InboundMeasurementOpenApiServiceTest {
|
||||
EventResponse first = new EventResponse("M202608010001", "RK202608010001", true,
|
||||
"RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z"));
|
||||
ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity();
|
||||
existing.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(BODY));
|
||||
existing.setRequestHash(requestHash());
|
||||
existing.setResultJson(mapper.writeValueAsString(first));
|
||||
existing.setProcessingStatus("ACCEPTED");
|
||||
when(fixture.logs.claimIdempotency(1L)).thenReturn(false);
|
||||
when(fixture.logs.findIdempotencyOwner(10001L, "device-provider", "idem-1")).thenReturn(existing);
|
||||
|
||||
EventResponse duplicate = fixture.service.receive(headers("nonce-2"), BODY);
|
||||
EventResponse duplicate = fixture.service.receive(headers("nonce-2"), BODY, image());
|
||||
|
||||
assertThat(duplicate).isEqualTo(first);
|
||||
verify(fixture.repository, never()).insertMeasurement(any());
|
||||
verify(fixture.repository, never()).insertItem(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一订单明细和第三方明细号已经绑定时在上传图片前拒绝")
|
||||
void shouldRejectAlreadyBoundExternalDetailBeforeUploadingImage() {
|
||||
Fixture fixture = fixture();
|
||||
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound());
|
||||
when(fixture.repository.findExternalDetail(10001L, 80001L, "BOX-001"))
|
||||
.thenReturn(new InboundMeasurementItemEntity());
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.receive(headers("nonce-3"), BODY, image()))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||
.hasMessageContaining("已绑定");
|
||||
|
||||
verify(fixture.images, never()).upload(anyString(),
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
|
||||
org.mockito.ArgumentMatchers.anyLong(), any());
|
||||
verify(fixture.repository, never()).insertMeasurement(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("图片已保存但测量落库失败时调用文件中心补偿清理")
|
||||
void shouldCompensateImageWhenMeasurementPersistenceFails() {
|
||||
Fixture fixture = fixture();
|
||||
when(fixture.repository.findInbound(10001L, "RK202608010001")).thenReturn(inbound());
|
||||
doThrow(new IllegalStateException("db failed"))
|
||||
.when(fixture.repository).insertMeasurement(any(InboundMeasurementEntity.class));
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.receive(headers("nonce-4"), BODY, image()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("db failed");
|
||||
|
||||
verify(fixture.images).delete("internal-token", 90001L, 10001L, 70001L, 99001L);
|
||||
verify(fixture.repository, never()).insertItem(any());
|
||||
}
|
||||
|
||||
private Fixture fixture() {
|
||||
InboundMeasurementOpenApiRepository repository = mock(InboundMeasurementOpenApiRepository.class);
|
||||
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
|
||||
ThirdPartyCallLogService logs = mock(ThirdPartyCallLogService.class);
|
||||
TransactionTemplate transactions = mock(TransactionTemplate.class);
|
||||
when(logs.start(anyString(), anyString(), any(), anyString())).thenReturn(1L);
|
||||
OrderCargoClient orderCargo = mock(OrderCargoClient.class);
|
||||
MeasurementImageClient images = mock(MeasurementImageClient.class);
|
||||
when(logs.start(anyString(), anyString(), any(), anyString(), anyString())).thenReturn(1L);
|
||||
when(logs.claimIdempotency(1L)).thenReturn(true);
|
||||
when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean()))
|
||||
.thenReturn(new AuthenticatedMeasurementApp("device-provider", 10001L, 90001L,
|
||||
Map.of("CN-YW-01", 30001L)));
|
||||
when(orderCargo.detail("internal-token", 10001L, 60001L, 80001L)).thenReturn(ApiResponse.success(
|
||||
new OrderCargoClient.CargoBinding(80001L, 60001L, 1, "测试货物", 5,
|
||||
new BigDecimal("62.500"), new BigDecimal("0.3010"))));
|
||||
when(images.upload(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.eq(90001L), org.mockito.ArgumentMatchers.eq(10001L),
|
||||
org.mockito.ArgumentMatchers.eq(70001L), any())).thenReturn(ApiResponse.success(
|
||||
new MeasurementImageClient.UploadResult(99001L, "measure.jpg", "image/jpeg", 13L,
|
||||
"NOT_REQUIRED", null, null, null, null)));
|
||||
when(transactions.execute(any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
TransactionCallback<EventResponse> callback = invocation.getArgument(0);
|
||||
return callback.doInTransaction(mock(TransactionStatus.class));
|
||||
});
|
||||
InboundMeasurementOpenApiService service = new InboundMeasurementOpenApiService(repository,
|
||||
authenticator, logs, objectMapper(),
|
||||
authenticator, logs, orderCargo, images, "internal-token", objectMapper(),
|
||||
Validation.buildDefaultValidatorFactory().getValidator(), transactions);
|
||||
return new Fixture(repository, logs, service);
|
||||
return new Fixture(repository, logs, orderCargo, images, service);
|
||||
}
|
||||
|
||||
private ObjectMapper objectMapper() {
|
||||
@@ -110,6 +176,7 @@ class InboundMeasurementOpenApiServiceTest {
|
||||
inbound.setId(70001L);
|
||||
inbound.setTenantId(10001L);
|
||||
inbound.setInboundCode("RK202608010001");
|
||||
inbound.setOrderId(60001L);
|
||||
inbound.setWarehouseId(30001L);
|
||||
inbound.setProcessStatus("WAIT_MEASURE");
|
||||
inbound.setDeleted(0);
|
||||
@@ -120,8 +187,20 @@ class InboundMeasurementOpenApiServiceTest {
|
||||
return new MeasurementRequestHeaders("device-provider", "1785551400000", nonce, "signature", "idem-1");
|
||||
}
|
||||
|
||||
private MockMultipartFile image() {
|
||||
return new MockMultipartFile("image", "measure.jpg", "image/jpeg", IMAGE);
|
||||
}
|
||||
|
||||
private String requestHash() throws Exception {
|
||||
String imageHash = java.util.HexFormat.of().formatHex(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(IMAGE));
|
||||
return MeasurementRequestAuthenticator.sha256Hex(BODY + "\n" + imageHash);
|
||||
}
|
||||
|
||||
private record Fixture(InboundMeasurementOpenApiRepository repository,
|
||||
ThirdPartyCallLogService logs,
|
||||
OrderCargoClient orderCargo,
|
||||
MeasurementImageClient images,
|
||||
InboundMeasurementOpenApiService service) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** 供头程履约服务校验入仓单所关联订单货物明细。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/order/v1/cargoes")
|
||||
public class InternalOrderCargoController {
|
||||
private final OrderRepository orders;
|
||||
private final byte[] expectedToken;
|
||||
|
||||
public InternalOrderCargoController(OrderRepository orders,
|
||||
@Value("${baiyue.security.internal-service-token}") String token) {
|
||||
this.orders = orders;
|
||||
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}/{cargoId}")
|
||||
public ApiResponse<CargoBinding> detail(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId,
|
||||
@PathVariable long cargoId) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || orderId <= 0 || cargoId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "租户、订单和货物明细ID必须大于0");
|
||||
}
|
||||
OrderCargoEntity cargo = orders.findCargo(tenantId, orderId, cargoId);
|
||||
if (cargo == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单");
|
||||
}
|
||||
return ApiResponse.success(new CargoBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(),
|
||||
cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm()));
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
|
||||
public record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount,
|
||||
java.math.BigDecimal originalGrossWeightKg,
|
||||
java.math.BigDecimal originalVolumeCbm) {
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,14 @@ public class OrderRepository {
|
||||
.orderByAsc(OrderCargoEntity::getItemNo));
|
||||
}
|
||||
|
||||
public OrderCargoEntity findCargo(long tenantId, long orderId, long cargoId) {
|
||||
return cargoMapper.selectOne(new LambdaQueryWrapper<OrderCargoEntity>()
|
||||
.eq(OrderCargoEntity::getTenantId, tenantId)
|
||||
.eq(OrderCargoEntity::getOrderId, orderId)
|
||||
.eq(OrderCargoEntity::getId, cargoId)
|
||||
.eq(OrderCargoEntity::getDeleted, false));
|
||||
}
|
||||
|
||||
public List<OrderPartyEntity> findParties(long tenantId, long orderId) {
|
||||
return partyMapper.selectList(new LambdaQueryWrapper<OrderPartyEntity>()
|
||||
.eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class InternalOrderCargoControllerTest {
|
||||
private final OrderRepository orders = mock(OrderRepository.class);
|
||||
private final InternalOrderCargoController controller =
|
||||
new InternalOrderCargoController(orders, "internal-token");
|
||||
|
||||
@Test
|
||||
void 只返回指定租户订单下的已有货物明细() {
|
||||
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||
cargo.setId(31L);
|
||||
cargo.setOrderId(21L);
|
||||
cargo.setItemNo(1);
|
||||
cargo.setCargoName("测试货物");
|
||||
cargo.setPackageCount(50);
|
||||
when(orders.findCargo(11L, 21L, 31L)).thenReturn(cargo);
|
||||
|
||||
var response = controller.detail("internal-token", 11L, 21L, 31L);
|
||||
|
||||
assertThat(response.successful()).isTrue();
|
||||
assertThat(response.data().cargoId()).isEqualTo(31L);
|
||||
assertThat(response.data().itemNo()).isEqualTo(1);
|
||||
verify(orders).findCargo(11L, 21L, 31L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 内部令牌错误时拒绝查询() {
|
||||
assertThatThrownBy(() -> controller.detail("bad-token", 11L, 21L, 31L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("凭证无效");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user