二期内容提交

This commit is contained in:
2026-08-04 12:23:46 +08:00
parent 25d2a13581
commit addde4375e
28 changed files with 897 additions and 132 deletions
+2
View File
@@ -5,6 +5,8 @@ spring:
password: ${FIRSTLEG_DB_PASSWORD} 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
+2
View File
@@ -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;
+7
View File
@@ -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("上传内容不是有效图片");
@@ -0,0 +1,43 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.MeasurementImageService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InternalMeasurementImageControllerTest {
private final MeasurementImageService images = mock(MeasurementImageService.class);
private final InternalMeasurementImageController controller =
new InternalMeasurementImageController(images, "internal-token");
@Test
void 保存第三方尺寸测量图片但不触发重量识别契约() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "measure.png",
MediaType.IMAGE_PNG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null);
when(images.uploadMeasurementEvidence(11L, 21L, 31L, file)).thenReturn(result);
var response = controller.upload("internal-token", 31L, 11L, 21L, file);
assertThat(response.data().fileId()).isEqualTo(41L);
assertThat(response.data().recognitionStatus()).isEqualTo("NOT_REQUIRED");
verify(images).uploadMeasurementEvidence(11L, 21L, 31L, file);
}
@Test
void 内部令牌错误时拒绝保存() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1});
assertThatThrownBy(() -> controller.upload("bad-token", 31L, 11L, 21L, file))
.isInstanceOf(BizException.class)
.hasMessageContaining("凭证无效");
}
}
@@ -0,0 +1,63 @@
package com.baiyue.file.service;
import com.baiyue.file.client.WeightOcrClient;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MeasurementImageServiceEvidenceTest {
@Test
void 尺寸测量证据图片落库但不调用重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
when(storage.store(11L, 21L, image)).thenReturn(new MeasurementImageStorage.Stored(
"measurements/11/21/a.png", Path.of("a.png"), MediaType.IMAGE_PNG_VALUE,
3L, "hash", "measure.png"));
doAnswer(invocation -> {
FileObjectEntity object = invocation.getArgument(0);
object.setId(41L);
return 1;
}).when(files).insert(any(FileObjectEntity.class));
var result = service.uploadMeasurementEvidence(11L, 21L, 31L, image);
assertThat(result.fileId()).isEqualTo(41L);
assertThat(result.recognitionStatus()).isEqualTo("NOT_REQUIRED");
verifyNoInteractions(ocr);
}
@Test
void 尺寸测量证据图片不能通过重试入口触发重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
FileObjectEntity object = new FileObjectEntity();
object.setId(41L);
object.setTenantId(11L);
object.setBusinessType("INBOUND_MEASUREMENT_IMAGE");
object.setBusinessId("21");
when(files.selectOne(any())).thenReturn(object);
assertThatThrownBy(() -> service.retryRecognition(11L, 21L, 41L))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("不执行重量OCR");
verifyNoInteractions(ocr);
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
@FeignClient(name = "file-service", contextId = "firstlegMeasurementImageClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/measurement-images")
public interface MeasurementImageClient {
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = "multipart/form-data")
ApiResponse<UploadResult> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file);
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
ApiResponse<Void> delete(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId);
record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
String recognitionStatus, BigDecimal weightValue, String weightUnit,
Double confidence, String error) {
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
public interface OrderCargoClient {
@GetMapping("/{tenantId}/{orderId}/{cargoId}")
ApiResponse<CargoBinding> detail(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId,
@PathVariable long cargoId);
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
}
}
@@ -12,19 +12,21 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import 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;
@@ -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));
}
} }
@@ -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}
@@ -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("不改变入库单状态");
} }
} }
@@ -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("凭证无效");
}
}