From 37fec1e0123b9c82b83cee89420b17de00bd3cd9 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 7 Aug 2026 18:44:51 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 12 ++
.../InternalProductImageController.java | 48 ++++++++
.../MerchantProductImageController.java | 62 ++++++++++
.../file/service/MeasurementImageService.java | 6 +-
.../file/service/ProductImageService.java | 76 +++++++++++++
.../file/service/ProductImageStorage.java | 107 ++++++++++++++++++
.../MeasurementImageServiceEvidenceTest.java | 48 +++++++-
.../PlatformInboundMeasurementController.java | 6 +-
.../dto/InboundMeasurementRequests.java | 8 +-
.../service/InboundMeasurementService.java | 41 +++++--
.../main/resources/openapi/firstleg-v1.yaml | 28 +++++
...oundMeasurementControllerContractTest.java | 3 +-
.../InboundMeasurementServiceTest.java | 54 ++++++++-
.../order/client/ProductImageClient.java | 19 ++++
.../com/baiyue/order/dto/OrderRequests.java | 8 +-
.../baiyue/order/entity/OrderCargoEntity.java | 2 +-
.../service/OrderApplicationService.java | 12 +-
.../order/service/ProductImageGuard.java | 58 ++++++++++
order/src/main/resources/application.yml | 2 +
order/src/main/resources/db/order.sql | 6 +
.../resources/db/phase1-order-extension.sql | 6 +
.../dto/OrderRequestsValidationTest.java | 3 +-
.../OrderApplicationServiceStateTest.java | 7 +-
.../service/OrderMasterDataGuardTest.java | 3 +-
24 files changed, 601 insertions(+), 24 deletions(-)
create mode 100644 file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java
create mode 100644 file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ProductImageService.java
create mode 100644 file/src/main/java/com/baiyue/file/service/ProductImageStorage.java
create mode 100644 order/src/main/java/com/baiyue/order/client/ProductImageClient.java
create mode 100644 order/src/main/java/com/baiyue/order/service/ProductImageGuard.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 513ae90b7..ec3695119 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -513,6 +513,12 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
product_usage VARCHAR(128) NULL COMMENT '用途',
model VARCHAR(64) NULL COMMENT '型号',
brand VARCHAR(64) NULL COMMENT '品牌',
+ product_length_cm DECIMAL(12,2) NULL COMMENT '产品长度(厘米)',
+ product_width_cm DECIMAL(12,2) NULL COMMENT '产品宽度(厘米)',
+ product_height_cm DECIMAL(12,2) NULL COMMENT '产品高度(厘米)',
+ shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID',
+ reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
+ product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -1546,6 +1552,12 @@ CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'");
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'");
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
+CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT 'Product length in centimeters'");
+CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT 'Product width in centimeters'");
+CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT 'Product height in centimeters'");
+CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
+CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
+CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
DROP PROCEDURE phase1_add_column;
diff --git a/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java b/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java
new file mode 100644
index 000000000..51a135d08
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/controller/InternalProductImageController.java
@@ -0,0 +1,48 @@
+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.ProductImageService;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.Resource;
+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/file/v1/product-images")
+public class InternalProductImageController {
+ @Resource private ProductImageService images;
+ @Value("${baiyue.security.internal-service-token}") private String internalToken;
+ private byte[] expectedToken;
+
+ @PostConstruct
+ void initializeToken() {
+ expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
+ }
+
+ @GetMapping("/{tenantId}/{fileId}")
+ public ApiResponse validate(
+ @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
+ @PathVariable long tenantId,
+ @PathVariable long fileId) {
+ requireToken(token);
+ if (tenantId <= 0 || fileId <= 0) {
+ throw new BizException(ResultCode.BAD_REQUEST, "客户和文件ID必须大于0");
+ }
+ return ApiResponse.success(images.validate(tenantId, fileId));
+ }
+
+ private void requireToken(String token) {
+ if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
+ throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
+ }
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java b/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java
new file mode 100644
index 000000000..060fea945
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/controller/MerchantProductImageController.java
@@ -0,0 +1,62 @@
+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.common.security.AuthenticatedUser;
+import com.baiyue.file.service.ProductImageService;
+import jakarta.annotation.Resource;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+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.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;
+
+@RestController
+@RequestMapping("/api/merchant/v1/files/product-images")
+public class MerchantProductImageController {
+ @Resource private ProductImageService images;
+
+ @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit')")
+ public ApiResponse upload(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @RequestPart("file") MultipartFile file) {
+ long tenantId = merchantTenant(user);
+ return ApiResponse.success(images.upload(tenantId, user.userId(), file));
+ }
+
+ @GetMapping("/{fileId}")
+ @PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
+ public ResponseEntity download(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable long fileId) {
+ var image = images.download(merchantTenant(user), fileId);
+ return ResponseEntity.ok()
+ .cacheControl(CacheControl.noStore())
+ .contentType(MediaType.parseMediaType(image.contentType()))
+ .contentLength(image.content().length)
+ .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
+ .filename(image.fileName(), StandardCharsets.UTF_8).build().toString())
+ .body(image.content());
+ }
+
+ private static long merchantTenant(AuthenticatedUser user) {
+ if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
+ || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null || user.userId() <= 0) {
+ throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required");
+ }
+ return user.tenantId();
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java
index 86532313d..82db4c2b3 100644
--- a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java
+++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java
@@ -87,11 +87,12 @@ public class MeasurementImageService {
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);
+ if (files.updateById(object) != 1 || files.deleteById(object.getId()) != 1) {
+ throw new BizException(ResultCode.CONFLICT, "测量图片已被删除,请刷新后重试");
+ }
storage.delete(object.getObjectKey());
}
@@ -101,6 +102,7 @@ public class MeasurementImageService {
.eq(FileObjectEntity::getId, fileId)
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
+ .eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
return object;
diff --git a/file/src/main/java/com/baiyue/file/service/ProductImageService.java b/file/src/main/java/com/baiyue/file/service/ProductImageService.java
new file mode 100644
index 000000000..083376fe5
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ProductImageService.java
@@ -0,0 +1,76 @@
+package com.baiyue.file.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.file.entity.FileObjectEntity;
+import com.baiyue.file.mapper.FileObjectMapper;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.time.LocalDateTime;
+
+@Service
+public class ProductImageService {
+ public static final String BUSINESS_TYPE = "ORDER_PRODUCT_IMAGE";
+ @Resource private ProductImageStorage storage;
+ @Resource private FileObjectMapper files;
+
+ @Transactional
+ public UploadResult upload(long tenantId, long operatorId, MultipartFile file) {
+ var stored = storage.store(tenantId, file);
+ FileObjectEntity object = new FileObjectEntity();
+ object.setTenantId(tenantId);
+ object.setBusinessType(BUSINESS_TYPE);
+ object.setOriginalName(stored.originalName());
+ object.setObjectKey(stored.objectKey());
+ object.setStorageProvider("LOCAL");
+ object.setBucketName("product-images");
+ object.setContentType(stored.contentType());
+ object.setSizeBytes(stored.size());
+ object.setSha256(stored.sha256());
+ object.setStatus("ACTIVE");
+ object.setCreatedBy(operatorId);
+ object.setUpdatedBy(operatorId);
+ object.setCreatedAt(LocalDateTime.now());
+ object.setUpdatedAt(LocalDateTime.now());
+ object.setDeleted(false);
+ object.setVersion(0);
+ try {
+ files.insert(object);
+ } catch (RuntimeException exception) {
+ storage.delete(stored.objectKey());
+ throw exception;
+ }
+ return metadata(object);
+ }
+
+ public UploadResult validate(long tenantId, long fileId) {
+ return metadata(requireObject(tenantId, fileId));
+ }
+
+ public Download download(long tenantId, long fileId) {
+ FileObjectEntity object = requireObject(tenantId, fileId);
+ return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
+ }
+
+ private FileObjectEntity requireObject(long tenantId, long fileId) {
+ FileObjectEntity object = files.selectOne(new LambdaQueryWrapper()
+ .eq(FileObjectEntity::getTenantId, tenantId)
+ .eq(FileObjectEntity::getId, fileId)
+ .eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE)
+ .eq(FileObjectEntity::getStatus, "ACTIVE")
+ .eq(FileObjectEntity::getDeleted, false));
+ if (object == null) throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在");
+ return object;
+ }
+
+ private static UploadResult metadata(FileObjectEntity object) {
+ return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes());
+ }
+
+ public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes) {}
+ public record Download(String fileName, String contentType, byte[] content) {}
+}
diff --git a/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java
new file mode 100644
index 000000000..1d73d2fb0
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java
@@ -0,0 +1,107 @@
+package com.baiyue.file.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import jakarta.annotation.PostConstruct;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.imageio.ImageIO;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+@Component
+public class ProductImageStorage {
+ private static final long MAX_SIZE = 5L * 1024 * 1024;
+ private static final Set TYPES = Set.of("image/jpeg", "image/png");
+ @Value("${baiyue.file.storage-root}") private Path configuredRoot;
+ private Path root;
+
+ @PostConstruct
+ void initializeRoot() {
+ root = configuredRoot.toAbsolutePath().normalize();
+ }
+
+ public Stored store(long tenantId, MultipartFile file) {
+ if (file == null || file.isEmpty()) throw bad("产品图片不能为空");
+ if (file.getSize() > MAX_SIZE) throw bad("产品图片不能超过5MB");
+ String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
+ if (!TYPES.contains(type)) throw bad("产品图片仅支持JPG、PNG");
+ verifyImage(file);
+ String extension = "image/png".equals(type) ? ".png" : ".jpg";
+ String objectKey = "product-images/" + tenantId + "/" + UUID.randomUUID() + extension;
+ Path target = root.resolve(objectKey).normalize();
+ if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
+ try {
+ Files.createDirectories(target.getParent());
+ file.transferTo(target);
+ return new Stored(objectKey, type, file.getSize(), sha256(target), safeName(file));
+ } catch (IOException exception) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片保存失败");
+ }
+ }
+
+ public byte[] read(String objectKey) {
+ Path path = root.resolve(objectKey).normalize();
+ if (!path.startsWith(root) || !Files.isRegularFile(path)) {
+ throw new BizException(ResultCode.NOT_FOUND, "产品图片不存在");
+ }
+ try {
+ return Files.readAllBytes(path);
+ } 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("上传内容不是有效图片");
+ } catch (IOException exception) {
+ throw bad("上传内容不是有效图片");
+ }
+ }
+
+ private static String sha256(Path path) throws IOException {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ try (InputStream input = Files.newInputStream(path)) {
+ byte[] buffer = new byte[8192];
+ for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
+ }
+ return HexFormat.of().formatHex(digest.digest());
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException(exception);
+ }
+ }
+
+ private static String safeName(MultipartFile file) {
+ String name = file.getOriginalFilename();
+ if (name == null || name.isBlank()) return "product-image";
+ return Path.of(name).getFileName().toString();
+ }
+
+ private static BizException bad(String message) {
+ return new BizException(ResultCode.BAD_REQUEST, message);
+ }
+
+ public record Stored(String objectKey, String contentType, long size, String sha256, String originalName) {}
+}
diff --git a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java
index 8b0b3ec3c..e579cedd5 100644
--- a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java
+++ b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java
@@ -24,6 +24,9 @@ import java.math.BigDecimal;
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.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -85,7 +88,7 @@ class MeasurementImageServiceEvidenceTest {
LambdaQueryWrapper query = invocation.getArgument(0);
query.getSqlSegment();
assertThat(query.getParamNameValuePairs().values())
- .contains(11L, 41L, "21", false);
+ .contains(11L, 41L, "21", "ACTIVE", false);
return null;
});
@@ -116,4 +119,47 @@ class MeasurementImageServiceEvidenceTest {
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
assertThat(result.ocrRawText()).isEqualTo("285.5\nKG");
}
+
+ @Test
+ void deletesMeasurementEvidenceThroughTheConfiguredLogicalDelete() {
+ FileObjectEntity object = measurementEvidence();
+ when(files.selectOne(any())).thenReturn(object);
+ when(files.updateById(object)).thenReturn(1);
+ when(files.deleteById(41L)).thenReturn(1);
+
+ service.deleteMeasurementEvidence(11L, 21L, 41L, 31L);
+
+ assertThat(object.getStatus()).isEqualTo("DELETED");
+ assertThat(object.getDeleted()).isFalse();
+ var ordered = inOrder(files, storage);
+ ordered.verify(files).updateById(object);
+ ordered.verify(files).deleteById(41L);
+ ordered.verify(storage).delete(object.getObjectKey());
+ }
+
+ @Test
+ void doesNotDeleteStoredContentWhenLogicalDeleteLosesTheRace() {
+ FileObjectEntity object = measurementEvidence();
+ when(files.selectOne(any())).thenReturn(object);
+ when(files.updateById(object)).thenReturn(1);
+ when(files.deleteById(41L)).thenReturn(0);
+
+ assertThatThrownBy(() -> service.deleteMeasurementEvidence(11L, 21L, 41L, 31L))
+ .isInstanceOf(com.baiyue.common.core.exception.BizException.class)
+ .hasMessageContaining("已被删除");
+
+ verify(storage, never()).delete(any());
+ }
+
+ private FileObjectEntity measurementEvidence() {
+ FileObjectEntity object = new FileObjectEntity();
+ object.setId(41L);
+ object.setTenantId(11L);
+ object.setBusinessType("INBOUND_MEASUREMENT_IMAGE");
+ object.setBusinessId("21");
+ object.setObjectKey("measurements/11/21/evidence.png");
+ object.setStatus("ACTIVE");
+ object.setDeleted(false);
+ return object;
+ }
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java
index db9f3d215..dc2423f2d 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java
@@ -151,9 +151,11 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
- @Operation(summary = "确认 OCR 重量", description = "统一换算为 KG 并重算所有已确认图片的总重量。")
+ @Operation(summary = "确认 OCR 重量",
+ description = "可在同一事务中携带人工修改值并确认;统一换算为 KG 后重算所有已确认图片的总重量。")
@PostMapping("/weight-results/{resultId}/confirm")
- @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')")
+ @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')"
+ + " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))")
public ApiResponse confirmWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java
index 6cdcff982..12d79d666 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java
@@ -94,7 +94,13 @@ public final class InboundMeasurementRequests {
}
public record ConfirmWeightResultRequest(
- @NotNull @PositiveOrZero Integer version
+ @NotNull @PositiveOrZero Integer version,
+ @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal adjustedWeightValue,
+ @Size(max = 8) String adjustedWeightUnit,
+ @Size(max = 500) String adjustmentReason
) {
+ public boolean hasAdjustment() {
+ return adjustedWeightValue != null || adjustedWeightUnit != null || adjustmentReason != null;
+ }
}
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
index aeefd08f9..5cea4873d 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
@@ -156,13 +156,22 @@ public class InboundMeasurementService {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
- String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
+ applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
+ request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
+ return toWeight(weight);
+ }
+
+ private int applyWeightCorrection(
+ InboundWeightResultEntity weight, int expectedVersion, BigDecimal adjustedWeightValue,
+ String adjustedWeightUnit, String adjustmentReason, long operatorId, String operatorName) {
+ requireCompleteAdjustment(adjustedWeightValue, adjustedWeightUnit, adjustmentReason);
+ String adjustedUnit = normalizeUnit(adjustedWeightUnit);
BigDecimal beforeValue = effectiveValue(weight);
String beforeUnit = effectiveUnit(weight);
- weight.setAdjustedWeightValue(request.adjustedWeightValue());
+ weight.setAdjustedWeightValue(adjustedWeightValue);
weight.setAdjustedWeightUnit(adjustedUnit);
- weight.setAdjustmentReason(request.adjustmentReason());
+ weight.setAdjustmentReason(adjustmentReason.trim());
weight.setAdjustedBy(operatorId);
weight.setAdjustedAt(LocalDateTime.now());
weight.setFinalWeightKg(null);
@@ -173,10 +182,12 @@ public class InboundMeasurementService {
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
- insertRevision(weight, request.version() + 1, "CORRECT", beforeValue, beforeUnit,
- request.adjustedWeightValue(), adjustedUnit, null, request.adjustmentReason(),
+ int correctedVersion = expectedVersion + 1;
+ weight.setVersion(correctedVersion);
+ insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit,
+ adjustedWeightValue, adjustedUnit, null, adjustmentReason.trim(),
operatorId, operatorName);
- return toWeight(weight);
+ return correctedVersion;
}
@Transactional
@@ -186,7 +197,12 @@ public class InboundMeasurementService {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
- if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
+ int currentVersion = request.version();
+ if (request.hasAdjustment()) {
+ currentVersion = applyWeightCorrection(weight, currentVersion,
+ request.adjustedWeightValue(), request.adjustedWeightUnit(),
+ request.adjustmentReason(), operatorId, operatorName);
+ } else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
return toWeight(weight);
}
BigDecimal value = effectiveValue(weight);
@@ -204,7 +220,9 @@ public class InboundMeasurementService {
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
- insertRevision(weight, request.version() + 1, "CONFIRM", value, unit, value, unit,
+ int confirmedVersion = currentVersion + 1;
+ weight.setVersion(confirmedVersion);
+ insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
finalKg, null, operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
@@ -235,6 +253,13 @@ public class InboundMeasurementService {
return isPositive(total) ? total : null;
}
+ private void requireCompleteAdjustment(BigDecimal value, String unit, String reason) {
+ if (!isPositive(value) || !StringUtils.hasText(unit) || !StringUtils.hasText(reason)) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "人工修改重量时必须同时填写有效重量、单位和修改原因");
+ }
+ }
+
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
BigDecimal beforeValue, String beforeUnit,
BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg,
diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
index bd05e15ca..258260170 100644
--- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml
+++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml
@@ -224,6 +224,25 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无测量编辑权限}
+ /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm:
+ post:
+ summary: 确认 OCR 重量
+ description: >-
+ 需要 platform:firstleg:inbound:weight:confirm 权限;携带人工修改字段时还需要
+ platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值,
+ 分别写入 CORRECT 和 CONFIRM 修订记录,并重算所有已确认图片的实际总毛重。
+ parameters:
+ - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ - {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/ConfirmWeightResultRequest'}
+ responses:
+ '200': {description: 成功或业务失败,按响应体 code 判断}
+ '403': {description: 无确认权限,或修改重量时无编辑权限}
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
@@ -339,6 +358,15 @@ components:
in: query
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
schemas:
+ ConfirmWeightResultRequest:
+ type: object
+ required: [version]
+ description: 人工修改字段必须同时提供;全部省略时直接确认现有 OCR 或已修正重量。
+ properties:
+ version: {type: integer, minimum: 0}
+ adjustedWeightValue: {type: number, format: double, minimum: 0.001, nullable: true}
+ adjustedWeightUnit: {type: string, enum: [KG, LB], nullable: true}
+ adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true}
OrderApprovedInboundCommand:
type: object
required: [tenantId, orderId, inboundCode, customerName, warehouseId]
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java
index 880a8db23..a720e8ee5 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java
@@ -35,7 +35,8 @@ class PlatformInboundMeasurementControllerContractTest {
assertAuthority("correctWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.CorrectWeightResultRequest.class);
- assertAuthority("confirmWeightResult", "hasAuthority('platform:firstleg:inbound:weight:confirm')",
+ assertAuthority("confirmWeightResult", "hasAuthority('platform:firstleg:inbound:weight:confirm')"
+ + " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.ConfirmWeightResultRequest.class);
}
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
index 5a0766501..fb480d621 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
@@ -22,10 +22,12 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -113,7 +115,7 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
- new InboundMeasurementRequests.ConfirmWeightResultRequest(0));
+ new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
@@ -121,6 +123,56 @@ class InboundMeasurementServiceTest {
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
}
+ @Test
+ void confirmingAdjustedWeightWritesCorrectionAndConfirmationInOneTransaction() {
+ InboundOrderEntity inbound = inbound("WAIT_MEASURE");
+ InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
+ when(repository.findInbound(1L, 10L)).thenReturn(inbound);
+ when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
+ when(repository.updateWeight(weight)).thenReturn(true);
+ when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
+ when(repository.updateInbound(inbound)).thenReturn(true);
+
+ InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
+ 1L, 10L, 20L, 9L, "操作员",
+ new InboundMeasurementRequests.ConfirmWeightResultRequest(
+ 0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG"));
+
+ assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125");
+ assertThat(response.finalWeightKg()).isEqualByComparingTo("286.125");
+ assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED");
+ assertThat(response.version()).isEqualTo(2);
+ assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125");
+ verify(repository, times(2)).updateWeight(weight);
+ ArgumentCaptor revisions =
+ ArgumentCaptor.forClass(InboundWeightRevisionEntity.class);
+ verify(repository, times(2)).insertRevision(revisions.capture());
+ assertThat(revisions.getAllValues()).extracting(
+ InboundWeightRevisionEntity::getRevisionNo,
+ InboundWeightRevisionEntity::getAction)
+ .containsExactly(tuple(1, "CORRECT"), tuple(2, "CONFIRM"));
+ assertThat(revisions.getAllValues().get(0).getReason())
+ .isEqualTo("OCR 原值 285.500 KG,人工确认 286.125 KG");
+ }
+
+ @Test
+ void confirmingRejectsPartialAdjustmentBeforePersisting() {
+ InboundOrderEntity inbound = inbound("WAIT_MEASURE");
+ InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 0);
+ when(repository.findInbound(1L, 10L)).thenReturn(inbound);
+ when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
+
+ assertThatThrownBy(() -> service.confirmWeightResult(
+ 1L, 10L, 20L, 9L, "操作员",
+ new InboundMeasurementRequests.ConfirmWeightResultRequest(
+ 0, new BigDecimal("286.125"), null, null)))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("有效重量、单位和修改原因");
+
+ verify(repository, never()).updateWeight(any());
+ verify(repository, never()).insertRevision(any());
+ }
+
@Test
void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING");
diff --git a/order/src/main/java/com/baiyue/order/client/ProductImageClient.java b/order/src/main/java/com/baiyue/order/client/ProductImageClient.java
new file mode 100644
index 000000000..c4c7cf428
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/client/ProductImageClient.java
@@ -0,0 +1,19 @@
+package com.baiyue.order.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;
+
+@FeignClient(name = "file-service", contextId = "orderProductImageClient",
+ url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/product-images")
+public interface ProductImageClient {
+ @GetMapping("/{tenantId}/{fileId}")
+ ApiResponse validate(
+ @RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable long tenantId,
+ @PathVariable long fileId);
+
+ record FileMetadata(Long fileId, String fileName, String contentType, Long sizeBytes) {}
+}
diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
index e455ac1b3..156cec35f 100644
--- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
+++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
@@ -135,7 +135,13 @@ public final class OrderRequests {
@Size(max = 128) String material,
@Size(max = 128) String usage,
@Size(max = 64) String model,
- @Size(max = 64) String brand
+ @Size(max = 64) String brand,
+ @DecimalMin("0.01") BigDecimal productLengthCm,
+ @DecimalMin("0.01") BigDecimal productWidthCm,
+ @DecimalMin("0.01") BigDecimal productHeightCm,
+ @Size(max = 64) String shipmentId,
+ @Size(max = 64) String referenceId,
+ @Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId
) {
}
diff --git a/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java
index e09ed5295..08a5236d4 100644
--- a/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java
+++ b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
* 申报价值及危险品标识等货物信息。
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
- @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand;
+ @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId;
}
diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
index aff208993..869d6e2b8 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
@@ -33,6 +33,7 @@ public class OrderApplicationService {
@Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
+ @Resource private ProductImageGuard productImageGuard;
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional
@@ -49,6 +50,7 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
validatePrototypeFields(request);
+ productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
OrderEntity order = new OrderEntity();
@@ -80,6 +82,7 @@ public class OrderApplicationService {
boolean allowSubmitted) {
OrderEntity order = require(tenantId, id);
validatePrototypeFields(request);
+ productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
OrderStatus before = order.getStatus();
if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED
@@ -189,8 +192,9 @@ public class OrderApplicationService {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : request.cargoes()) {
- if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())) {
- throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
+ if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())
+ || !StringUtils.hasText(cargo.shipmentId()) || !StringUtils.hasText(cargo.referenceId())) {
+ throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
}
}
}
@@ -384,6 +388,10 @@ public class OrderApplicationService {
item.setTotalQuantity(cargo.totalQuantity()); item.setNetWeightKg(cargo.netWeightKg());
item.setUnitDeclaredValue(cargo.unitDeclaredValue()); item.setMaterial(cargo.material());
item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand());
+ item.setProductLengthCm(cargo.productLengthCm()); item.setProductWidthCm(cargo.productWidthCm());
+ item.setProductHeightCm(cargo.productHeightCm()); item.setShipmentId(cargo.shipmentId());
+ item.setReferenceId(cargo.referenceId());
+ item.setProductImageFileId(cargo.productImageFileId());
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender());
diff --git a/order/src/main/java/com/baiyue/order/service/ProductImageGuard.java b/order/src/main/java/com/baiyue/order/service/ProductImageGuard.java
new file mode 100644
index 000000000..a70a1026c
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/service/ProductImageGuard.java
@@ -0,0 +1,58 @@
+package com.baiyue.order.service;
+
+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.client.ProductImageClient;
+import com.baiyue.order.dto.OrderRequests;
+import feign.FeignException;
+import jakarta.annotation.Resource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class ProductImageGuard {
+ @Resource private ProductImageClient client;
+ @Value("${baiyue.security.internal-service-token}") private String internalToken;
+
+ public void requireOwned(long tenantId, List cargoes) {
+ Set validated = new HashSet<>();
+ for (OrderRequests.Cargo cargo : cargoes) {
+ Long fileId = cargo.productImageFileId();
+ if (fileId == null || !validated.add(fileId)) continue;
+ if (fileId <= 0) throw new BizException(ResultCode.VALIDATION_FAILED, "产品图片文件ID无效");
+ validate(tenantId, fileId);
+ }
+ }
+
+ private void validate(long tenantId, long fileId) {
+ try {
+ ApiResponse response = client.validate(internalToken, tenantId, fileId);
+ if (response == null) throw unavailable();
+ if (!response.successful()) {
+ ResultCode code = ResultCode.of(response.code());
+ String message = response.message() == null || response.message().isBlank()
+ ? code.message() : response.message();
+ throw new BizException(code, message);
+ }
+ if (response.data() == null || response.data().fileId() == null
+ || response.data().fileId() != fileId) {
+ throw unavailable();
+ }
+ } catch (BizException exception) {
+ throw exception;
+ } catch (FeignException exception) {
+ throw unavailable();
+ } catch (RuntimeException exception) {
+ throw unavailable();
+ }
+ }
+
+ private static BizException unavailable() {
+ return new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片校验服务暂时不可用");
+ }
+}
diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml
index 17e31bf60..19677f6c6 100644
--- a/order/src/main/resources/application.yml
+++ b/order/src/main/resources/application.yml
@@ -33,6 +33,8 @@ baiyue:
url: ${IAM_SERVICE_URL:}
warehouse:
url: ${WAREHOUSE_SERVICE_URL:}
+ file:
+ url: ${FILE_SERVICE_URL:}
logging:
file:
diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql
index 9c51385fa..f8697e490 100644
--- a/order/src/main/resources/db/order.sql
+++ b/order/src/main/resources/db/order.sql
@@ -111,6 +111,12 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
product_usage VARCHAR(128) NULL COMMENT '用途',
model VARCHAR(64) NULL COMMENT '型号',
brand VARCHAR(64) NULL COMMENT '品牌',
+ product_length_cm DECIMAL(12,2) NULL COMMENT '产品长度(厘米)',
+ product_width_cm DECIMAL(12,2) NULL COMMENT '产品宽度(厘米)',
+ product_height_cm DECIMAL(12,2) NULL COMMENT '产品高度(厘米)',
+ shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID',
+ reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
+ product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql
index 44cfc7162..426311429 100644
--- a/order/src/main/resources/db/phase1-order-extension.sql
+++ b/order/src/main/resources/db/phase1-order-extension.sql
@@ -54,6 +54,12 @@ CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'");
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'");
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
+CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT 'Product length in centimeters'");
+CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT 'Product width in centimeters'");
+CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT 'Product height in centimeters'");
+CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
+CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
+CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
DROP PROCEDURE phase1_add_column;
diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java
index 0e74ff3ba..9ffd224f5 100644
--- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java
+++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java
@@ -39,7 +39,8 @@ class OrderRequestsValidationTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
- null, null, null, null, null, null, null, null, null);
+ null, null, null, null, null, null, null, null, null,
+ null, null, null, null, null, null);
var request = new OrderRequests.SaveRequest(
null, "当前企业", "SEA", "FOB", "US", 193000000000200003L, "洛杉矶仓",
null, null, null, null, List.of(cargo), "SELF_EXPRESS", 193000000000200001L,
diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
index 7dfdd7856..31f02569c 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
@@ -398,6 +398,7 @@ class OrderApplicationServiceStateTest {
ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility);
ReflectionTestUtils.setField(service, "masterDataGuard", masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", mock(OrderPricingChannelGuard.class));
+ ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
return service;
}
@@ -408,7 +409,8 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
- null, null, null, null, null, null, null, null, null);
+ null, null, null, null, null, null, null, null, null,
+ null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司",
@@ -421,7 +423,8 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
- null, null, null, null, null, null, null, null, null);
+ null, null, null, null, null, null, null, null, null,
+ null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司",
diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java
index d74426d6a..0abaf9e5b 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java
@@ -253,7 +253,8 @@ class OrderMasterDataGuardTest {
private OrderRequests.SaveRequest request(Long partner,Long warehouse,String destinationType,Long destinationWarehouseId) {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
- null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null);
+ null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null,
+ null,null,null,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null);
}
private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) {