This commit is contained in:
2026-08-07 19:23:40 +08:00
parent 37fec1e012
commit c698290106
39 changed files with 1897 additions and 13 deletions
@@ -227,8 +227,10 @@ public class OrderApplicationService {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : cargoes) {
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())) {
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())
|| !StringUtils.hasText(cargo.getShipmentId())
|| !StringUtils.hasText(cargo.getReferenceId())) {
throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
}
}
}
@@ -61,6 +61,15 @@ CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMME
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'");
-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。
UPDATE ord_order_cargo cargo
JOIN ord_order orders ON orders.id = cargo.order_id AND orders.tenant_id = cargo.tenant_id
SET cargo.shipment_id = COALESCE(NULLIF(cargo.shipment_id, ''), orders.shipment_id),
cargo.reference_id = COALESCE(NULLIF(cargo.reference_id, ''), orders.reference_id)
WHERE orders.destination_type = 'FBA'
AND ((cargo.shipment_id IS NULL OR cargo.shipment_id = '')
OR (cargo.reference_id IS NULL OR cargo.reference_id = ''));
DROP PROCEDURE phase1_add_column;
-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。
+43 -2
View File
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.4.0
version: 1.5.0
security:
- bearerAuth: []
paths:
@@ -241,12 +241,53 @@ components:
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
FirstLegOrderDraft:
type: object
required: [pricingTaxMode]
required: [pricingTaxMode, cargoes]
properties:
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
cargoes:
type: array
minItems: 1
items: {$ref: '#/components/schemas/FirstLegOrderCargo'}
description: >-
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。
FirstLegOrderCargo:
type: object
required: [cargoName, packageType, packageCount, grossWeightKg, volumeCbm]
properties:
skuNo: {type: string, maxLength: 64}
cargoName: {type: string, maxLength: 128}
cargoNameEn: {type: string, maxLength: 128}
productImageFileId: {type: string, pattern: '^[1-9][0-9]*$'}
hsCode: {type: string, maxLength: 20, description: 报关 HS CODE}
customsHsCode: {type: string, maxLength: 20, description: 清关 HS CODE}
lengthCm: {type: number, minimum: 0, description: 箱规长(厘米)}
widthCm: {type: number, minimum: 0, description: 箱规宽(厘米)}
heightCm: {type: number, minimum: 0, description: 箱规高(厘米)}
unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量}
packageCount: {type: integer, minimum: 1, description: 箱数}
grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重}
volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积}
totalQuantity: {type: integer, minimum: 1, description: 总数量}
netWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总净重}
unitDeclaredValue: {type: number, minimum: 0, description: 清关申报单价(USD)}
declaredValue: {type: number, minimum: 0, description: 清关申报金额(USD)}
material: {type: string, maxLength: 128}
usage: {type: string, maxLength: 128}
model: {type: string, maxLength: 64}
brand: {type: string, maxLength: 64}
productLengthCm: {type: number, minimum: 0}
productWidthCm: {type: number, minimum: 0}
productHeightCm: {type: number, minimum: 0}
shipmentId: {type: string, maxLength: 64}
referenceId: {type: string, maxLength: 64}
upcNo: {type: string, maxLength: 64}
cargoRemark: {type: string, maxLength: 500}
packageType: {type: string, maxLength: 32}
dangerousGoods: {type: boolean}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
productImageFileId 必须属于当前登录客户。
FirstLegOrderSummary:
type: object
properties:
@@ -35,6 +35,7 @@ class OrderApplicationServiceStateTest {
@MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@MockBean private ProductImageGuard contextProductImageGuard;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -40,6 +40,7 @@ class OrderIdempotencyServiceTest {
@MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@MockBean private ProductImageGuard contextProductImageGuard;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -19,6 +19,7 @@ class PlatformOrderPageServiceTest {
@MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@@ -0,0 +1,75 @@
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 jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(ProductImageGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
class ProductImageGuardTest {
@MockBean private ProductImageClient client;
@Resource private ProductImageGuard guard;
@Test
void validatesEachDistinctImageAgainstTheCurrentCustomer() {
var metadata = new ProductImageClient.FileMetadata(31L, "product.png", "image/png", 100L);
when(client.validate("token", 10001L, 31L)).thenReturn(ApiResponse.success(metadata));
guard.requireOwned(10001L, List.of(cargo(31L), cargo(31L)));
verify(client, times(1)).validate("token", 10001L, 31L);
}
@Test
void preservesCrossCustomerOrMissingImageRejection() {
when(client.validate("token", 10001L, 31L)).thenReturn(
ApiResponse.failure(ResultCode.NOT_FOUND, "产品图片不存在", null, null));
assertThatThrownBy(() -> guard.requireOwned(10001L, List.of(cargo(31L))))
.isInstanceOf(BizException.class).hasMessage("产品图片不存在");
}
@Test
void mapsUnavailableOrMalformedResponsesToServiceUnavailable() {
when(client.validate("token", 10001L, 31L)).thenReturn(null);
assertUnavailable(() -> guard.requireOwned(10001L, List.of(cargo(31L))));
when(client.validate("token", 10001L, 31L)).thenReturn(
ApiResponse.success(new ProductImageClient.FileMetadata(32L, "wrong.png", "image/png", 10L)));
assertUnavailable(() -> guard.requireOwned(10001L, List.of(cargo(31L))));
}
@Test
void rejectsInvalidLogicalIdBeforeCallingFileService() {
assertThatThrownBy(() -> guard.requireOwned(10001L, List.of(cargo(0L))))
.isInstanceOf(BizException.class).hasMessageContaining("文件ID无效");
}
private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) {
assertThatThrownBy(invocation).isInstanceOf(BizException.class)
.hasMessage("产品图片校验服务暂时不可用")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
private static OrderRequests.Cargo cargo(Long fileId) {
return 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, fileId);
}
}
@@ -22,6 +22,7 @@ class TenantOrderCountServiceTest {
@MockBean private OrderEligibilityGuard eligibilityGuard;
@MockBean private OrderMasterDataGuard masterDataGuard;
@MockBean private OrderPricingChannelGuard pricingChannelGuard;
@MockBean private ProductImageGuard productImageGuard;
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {