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
@@ -1559,6 +1559,14 @@ 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'");
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;
-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。
@@ -3464,6 +3472,62 @@ SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- 客户尺寸计算配置权限;与 FBA 计费规则相互独立。
DROP PROCEDURE IF EXISTS unified_guard_dimension_config_permissions;
DELIMITER $$
CREATE PROCEDURE unified_guard_dimension_config_permissions()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view')
OR (id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit')
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限固定ID已被其他权限码占用';
END IF;
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (permission_code = 'firstleg:dimension-config:view' AND id <> 5102111)
OR (permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限码已绑定其他固定ID';
END IF;
END$$
DELIMITER ;
CALL unified_guard_dimension_config_permissions();
DROP PROCEDURE IF EXISTS unified_guard_dimension_config_permissions;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102111, 0, 'PLATFORM', 'BUTTON', 5100010, 'firstleg:dimension-config:view',
'查看客户尺寸计算配置', NULL, NULL, 90, 'ACTIVE', 1, 0),
(5102112, 0, 'PLATFORM', 'BUTTON', 5100010, 'firstleg:dimension-config:edit',
'维护客户尺寸计算配置', NULL, NULL, 100, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT r.id, p.id, 0
FROM iam_role r
JOIN iam_permission p
ON p.tenant_id = 0 AND p.app_code = 'PLATFORM'
AND p.permission_code IN ('firstleg:dimension-config:view', 'firstleg:dimension-config:edit')
WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'PLATFORM_ADMIN'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT r.id, p.id, 0
FROM iam_role r
JOIN iam_permission p
ON p.tenant_id = 0 AND p.app_code = 'PLATFORM'
AND p.permission_code = 'firstleg:dimension-config:view'
WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- ============================================================================
-- 来源:iam\src\main\resources\db\phase2-location-permissions.sql
-- ============================================================================
@@ -3745,7 +3809,8 @@ SET @phase2_permission_count = (
'firstleg:fba-pricing:rule:edit', 'firstleg:fba-pricing:rate:view',
'firstleg:fba-pricing:rate:edit', 'firstleg:fba-pricing:calculate',
'firstleg:fba-pricing:confirm', 'firstleg:fba-pricing:view',
'firstleg:fba-pricing:adjust', 'platform:location:list',
'firstleg:fba-pricing:adjust', 'firstleg:dimension-config:view',
'firstleg:dimension-config:edit', 'platform:location:list',
'platform:location:create', 'platform:location:edit', 'platform:location:status',
'platform:location:delete', 'platform:pallet:list', 'platform:pallet:create',
'platform:pallet:edit', 'platform:pallet:delete'
@@ -3756,9 +3821,9 @@ DROP PROCEDURE IF EXISTS unified_assert_permission_count;
DELIMITER $$
CREATE PROCEDURE unified_assert_permission_count()
BEGIN
IF @phase2_permission_count <> 28 THEN
IF @phase2_permission_count <> 30 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 28 项';
SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 30 项';
END IF;
END$$
DELIMITER ;
@@ -5013,6 +5078,24 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
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',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
DELIMITER $$
CREATE PROCEDURE unified_assert_final_extensions()
@@ -5333,6 +5416,59 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:稳定箱号或单箱测量唯一约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config') <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置表未创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config'
AND index_name = 'uk_customer_dimension_config_tenant'
AND non_unique = 0) <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置唯一约束未创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE id = 5100010 AND tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code = 'platform:customer:list' AND permission_name = '企业客户'
AND status = 'ACTIVE' AND deleted = 0) <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限父节点企业客户不存在';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type = 'BUTTON'
AND parent_id = 5100010 AND status = 'ACTIVE' AND deleted = 0
AND ((id = 5102111 AND permission_code = 'firstleg:dimension-config:view')
OR (id = 5102112 AND permission_code = 'firstleg:dimension-config:edit'))) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置权限码、固定ID或父节点不正确';
END IF;
IF (SELECT COUNT(DISTINCT p.permission_code)
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id
WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'PLATFORM_ADMIN'
AND p.permission_code IN ('firstleg:dimension-config:view', 'firstleg:dimension-config:edit')) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:平台管理员未获得客户尺寸计算配置读写权限';
END IF;
IF (SELECT COUNT(DISTINCT p.permission_code)
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id
WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR'
AND p.permission_code = 'firstleg:dimension-config:view') <> 1
OR (SELECT COUNT(*)
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id
WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' AND r.role_code = 'ORDER_OPERATOR'
AND p.permission_code = 'firstleg:dimension-config:edit') <> 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:订单运营角色的客户尺寸计算配置权限不是只读';
END IF;
END$$
DELIMITER ;
CALL unified_assert_final_extensions();
@@ -8,6 +8,8 @@ import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
@@ -15,6 +17,7 @@ import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Iterator;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@@ -36,7 +39,7 @@ public class ProductImageStorage {
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);
verifyImage(file, type);
String extension = "image/png".equals(type) ? ".png" : ".jpg";
String objectKey = "product-images/" + tenantId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
@@ -72,9 +75,23 @@ public class ProductImageStorage {
}
}
private void verifyImage(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
private void verifyImage(MultipartFile file, String declaredType) {
try (InputStream input = file.getInputStream();
ImageInputStream imageInput = ImageIO.createImageInputStream(input)) {
if (imageInput == null) throw bad("上传内容不是有效图片");
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInput);
if (!readers.hasNext()) throw bad("上传内容不是有效图片");
ImageReader reader = readers.next();
try {
String format = reader.getFormatName().toLowerCase(Locale.ROOT);
boolean matches = "image/png".equals(declaredType) ? "png".equals(format)
: "jpeg".equals(format) || "jpg".equals(format);
if (!matches) throw bad("图片内容与文件类型不一致");
reader.setInput(imageInput, true, true);
reader.read(0);
} finally {
reader.dispose();
}
} catch (IOException exception) {
throw bad("上传内容不是有效图片");
}
@@ -96,7 +113,11 @@ public class ProductImageStorage {
private static String safeName(MultipartFile file) {
String name = file.getOriginalFilename();
if (name == null || name.isBlank()) return "product-image";
return Path.of(name).getFileName().toString();
String normalized = name.replace('\\', '/');
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1)
.replaceAll("[\\p{Cntrl}]", "").trim();
if (leaf.isEmpty()) return "product-image";
return leaf.length() <= 255 ? leaf : leaf.substring(leaf.length() - 255);
}
private static BizException bad(String message) {
@@ -0,0 +1,83 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.ProductImageService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Set;
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 ProductImageControllerContractTest {
@Test
void merchantRouteUsesMultipartAndOrderPermissions() throws Exception {
RequestMapping mapping = MerchantProductImageController.class.getAnnotation(RequestMapping.class);
assertThat(mapping.value()).contains("/api/merchant/v1/files/product-images");
var upload = MerchantProductImageController.class.getDeclaredMethod(
"upload", AuthenticatedUser.class, org.springframework.web.multipart.MultipartFile.class);
assertThat(upload.getAnnotation(PostMapping.class).consumes())
.contains(MediaType.MULTIPART_FORM_DATA_VALUE);
assertThat(upload.getAnnotation(PreAuthorize.class).value())
.contains("merchant:order:create").contains("merchant:order:edit");
}
@Test
void merchantUploadAndDownloadAlwaysUseAuthenticatedCustomer() {
ProductImageService service = mock(ProductImageService.class);
MerchantProductImageController controller = new MerchantProductImageController();
ReflectionTestUtils.setField(controller, "images", service);
var user = merchant(10001L);
var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1});
var uploaded = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L);
when(service.upload(10001L, 2L, file)).thenReturn(uploaded);
when(service.download(10001L, 31L)).thenReturn(
new ProductImageService.Download("product.png", "image/png", new byte[] {1}));
assertThat(controller.upload(user, file).data()).isEqualTo(uploaded);
assertThat(controller.download(user, 31L).getBody()).containsExactly(1);
verify(service).upload(10001L, 2L, file);
verify(service).download(10001L, 31L);
}
@Test
void internalValidationRequiresSharedTokenAndForwardsCustomerScope() {
ProductImageService service = mock(ProductImageService.class);
InternalProductImageController controller = new InternalProductImageController();
ReflectionTestUtils.setField(controller, "images", service);
ReflectionTestUtils.setField(controller, "internalToken", "token");
controller.initializeToken();
var metadata = new ProductImageService.UploadResult(31L, "product.png", "image/png", 1L);
when(service.validate(10001L, 31L)).thenReturn(metadata);
assertThat(controller.validate("token", 10001L, 31L).data()).isEqualTo(metadata);
assertThatThrownBy(() -> controller.validate("wrong", 10001L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
verify(service).validate(10001L, 31L);
}
@Test
void rejectsPlatformOrMalformedIdentityBeforeFileAccess() {
ProductImageService service = mock(ProductImageService.class);
MerchantProductImageController controller = new MerchantProductImageController();
ReflectionTestUtils.setField(controller, "images", service);
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of());
var file = new MockMultipartFile("file", "product.png", "image/png", new byte[] {1});
assertThatThrownBy(() -> controller.upload(platform, file)).isInstanceOf(BizException.class);
}
private static AuthenticatedUser merchant(long tenantId) {
return new AuthenticatedUser(2L, tenantId, tenantId, 2L, "merchant", "MERCHANT", "WEB", Set.of());
}
}
@@ -0,0 +1,62 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ProductImageStorageTest {
@TempDir Path root;
@Test
void storesValidatedImageUnderCustomerDirectoryAndReadsItBack() throws Exception {
ProductImageStorage storage = storage();
byte[] content = png();
var stored = storage.store(10001L,
new MockMultipartFile("file", "../product.png", "image/png", content));
assertThat(stored.objectKey()).startsWith("product-images/10001/").endsWith(".png");
assertThat(stored.originalName()).isEqualTo("product.png");
assertThat(storage.read(stored.objectKey())).isEqualTo(content);
}
@Test
void rejectsEmptyOversizedForgedAndMismatchedFiles() throws Exception {
ProductImageStorage storage = storage();
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "empty.png", "image/png", new byte[0])))
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "large.png", "image/png", new byte[5 * 1024 * 1024 + 1])))
.isInstanceOf(BizException.class).hasMessageContaining("5MB");
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "fake.jpg", "image/jpeg", "not-an-image".getBytes())))
.isInstanceOf(BizException.class).hasMessageContaining("有效图片");
assertThatThrownBy(() -> storage.store(10001L,
new MockMultipartFile("file", "mismatch.jpg", "image/jpeg", png())))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
private ProductImageStorage storage() {
ProductImageStorage storage = new ProductImageStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
private static byte[] png() throws Exception {
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB), "png", output);
return output.toByteArray();
}
}
@@ -0,0 +1,61 @@
package com.baiyue.firstleg.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.firstleg.dto.CustomerDimensionConfigDtos.InitializeRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestParam;
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/firstleg/v1/customer-dimension-configs")
public class InternalCustomerDimensionConfigController {
@Resource
private CustomerDimensionConfigService service;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@PostConstruct
void initializeToken() {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/defaults")
public ApiResponse<InitializeResponse> initializeDefault(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody InitializeRequest request) {
requireToken(token);
return ApiResponse.success(service.initializeDefault(
request.tenantId(), request.operatorId(), request.initializationKey()));
}
@DeleteMapping("/defaults")
public ApiResponse<Void> compensateDefault(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam long tenantId, @RequestParam String initializationKey) {
requireToken(token);
service.compensateDefault(tenantId, initializationKey);
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, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "客户尺寸计算配置")
@RestController
@RequestMapping("/api/platform/v1/first-leg/customer-dimension-config")
public class PlatformCustomerDimensionConfigController {
@Resource
private CustomerDimensionConfigService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:dimension-config:view')")
@Operation(summary = "查询客户尺寸计算配置")
public ApiResponse<Response> get(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId) {
return ApiResponse.success(service.get(FirstlegScopeGuard.platform(user, tenantId)));
}
@PutMapping
@PreAuthorize("hasAuthority('firstleg:dimension-config:edit')")
@BusinessAudit(event = "CUSTOMER_DIMENSION_CONFIG_UPDATE",
resourceType = "DIMENSION_CONFIG", resourceId = "#tenantId",
description = "平台编辑客户尺寸计算配置")
@Operation(summary = "编辑客户尺寸计算配置")
public ApiResponse<Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody UpdateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.update(scope, user.userId(), request));
}
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class CustomerDimensionConfigDtos {
private CustomerDimensionConfigDtos() { }
public record UpdateRequest(
@NotNull @DecimalMin("0.001") @DecimalMax("100.000") @Digits(integer = 3, fraction = 3)
BigDecimal edgeAdditionCm,
@Size(max = 500) String remark,
@NotNull @Min(0) Integer version
) { }
public record Response(
Long id,
Long tenantId,
BigDecimal edgeAdditionCm,
String remark,
Integer version,
LocalDateTime createdAt,
LocalDateTime updatedAt
) { }
public record InitializeRequest(
@NotNull @Min(1) Long tenantId,
@NotNull @Min(0) Long operatorId,
@NotBlank @Size(max = 128) String initializationKey
) { }
public record InitializeResponse(Long configId, boolean createdByThisInitialization) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 客户填写真实尺寸后使用的每侧加宽配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
public class CustomerDimensionConfigEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private BigDecimal edgeAdditionCm;
private String remark;
private String initializationKey;
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimensionConfigEntity> {
@Insert("""
INSERT IGNORE INTO fl_customer_dimension_config
(id, tenant_id, edge_addition_cm, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version)
VALUES
(#{id}, #{tenantId}, #{edgeAdditionCm}, #{remark}, #{initializationKey}, #{operatorId}, CURRENT_TIMESTAMP(3),
#{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
""")
int insertDefault(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm,
@Param("remark") String remark, @Param("initializationKey") String initializationKey,
@Param("operatorId") long operatorId);
@Delete("""
DELETE FROM fl_customer_dimension_config
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND initialization_key = #{initializationKey}
AND edge_addition_cm = #{edgeAdditionCm}
AND version = 0
AND deleted = 0
""")
int deleteUnchangedDefault(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("initializationKey") String initializationKey,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm);
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
@Repository
public class CustomerDimensionConfigRepository {
@Resource
private CustomerDimensionConfigMapper mapper;
public CustomerDimensionConfigEntity find(long tenantId) {
return mapper.selectOne(new LambdaQueryWrapper<CustomerDimensionConfigEntity>()
.eq(CustomerDimensionConfigEntity::getTenantId, tenantId));
}
public boolean insertDefault(long id, long tenantId, BigDecimal edgeAdditionCm,
String remark, String initializationKey, long operatorId) {
return mapper.insertDefault(id, tenantId, edgeAdditionCm, remark, initializationKey, operatorId) == 1;
}
public int update(CustomerDimensionConfigEntity value) {
return mapper.updateById(value);
}
public int deleteUnchangedDefault(long tenantId, long id, String initializationKey,
BigDecimal edgeAdditionCm) {
return mapper.deleteUnchangedDefault(tenantId, id, initializationKey, edgeAdditionCm);
}
}
@@ -0,0 +1,102 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
/** 管理每个客户唯一的尺寸加边配置,不参与当前订单或计费计算。 */
@Service
public class CustomerDimensionConfigService {
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
private static final String DEFAULT_REMARK = "默认每侧加宽 2cm";
@Resource
private CustomerDimensionConfigRepository repository;
public Response get(long tenantId) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
}
return response(value);
}
@Transactional
public Response update(long tenantId, long operatorId, UpdateRequest request) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
}
if (!value.getVersion().equals(request.version())) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
}
value.setEdgeAdditionCm(request.edgeAdditionCm());
value.setRemark(normalizeRemark(request.remark()));
value.setUpdatedBy(operatorId);
if (repository.update(value) != 1) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
}
return response(value);
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing != null) {
return new InitializeResponse(existing.getId(),
normalizedKey.equals(existing.getInitializationKey()));
}
long id = IdWorker.getId();
boolean created = repository.insertDefault(id, tenantId, DEFAULT_EDGE_ADDITION_CM,
DEFAULT_REMARK, normalizedKey, operatorId);
CustomerDimensionConfigEntity persisted = repository.find(tenantId);
if (persisted == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户尺寸计算配置初始化失败");
}
boolean ownedByKey = normalizedKey.equals(persisted.getInitializationKey());
return new InitializeResponse(persisted.getId(), created || ownedByKey);
}
@Transactional
public void compensateDefault(long tenantId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing == null || !normalizedKey.equals(existing.getInitializationKey())) return;
repository.deleteUnchangedDefault(tenantId, existing.getId(), normalizedKey, DEFAULT_EDGE_ADDITION_CM);
}
private static Response response(CustomerDimensionConfigEntity value) {
return new Response(value.getId(), value.getTenantId(), value.getEdgeAdditionCm(), value.getRemark(),
value.getVersion(), value.getCreatedAt(), value.getUpdatedAt());
}
private static String normalizeRemark(String value) {
if (value == null || value.isBlank()) return null;
return value.trim();
}
private static void requireTenant(long tenantId) {
if (tenantId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户ID无效");
}
private static String requireInitializationKey(String value) {
if (value == null || value.isBlank() || value.trim().length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "初始化幂等键无效");
}
return value.trim();
}
}
@@ -199,6 +199,9 @@ public class InboundMeasurementService {
requireVersion(request.version(), weight.getVersion());
int currentVersion = request.version();
if (request.hasAdjustment()) {
requireCompleteAdjustment(request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason());
requireConfirmableKg(request.adjustedWeightValue(), request.adjustedWeightUnit());
currentVersion = applyWeightCorrection(weight, currentVersion,
request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason(), operatorId, operatorName);
@@ -211,7 +214,7 @@ public class InboundMeasurementService {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位");
}
String unit = normalizeUnit(rawUnit);
BigDecimal finalKg = toKg(value, unit);
BigDecimal finalKg = requireConfirmableKg(value, unit);
weight.setFinalWeightKg(finalKg);
weight.setRecognitionStatus(WEIGHT_CONFIRMED);
weight.setConfirmedBy(operatorId);
@@ -418,6 +421,15 @@ public class InboundMeasurementService {
return kg.setScale(3, RoundingMode.HALF_UP);
}
private BigDecimal requireConfirmableKg(BigDecimal value, String unit) {
BigDecimal finalKg = toKg(value, normalizeUnit(unit));
if (!isPositive(finalKg)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"换算后重量不能小于 0.001 KG");
}
return finalKg;
}
private BigDecimal effectiveValue(InboundWeightResultEntity weight) {
return weight.getAdjustedWeightValue() == null
? weight.getOcrWeightValue() : weight.getAdjustedWeightValue();
@@ -0,0 +1,21 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 每个客户唯一的尺寸计算配置;当前只保存每侧加宽值,不接入订单或计费。
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
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',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
@@ -729,3 +729,21 @@ CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key),
KEY idx_inbound_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录';
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
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',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
@@ -6,6 +6,51 @@ info:
security:
- bearerAuth: []
paths:
/internal/firstleg/v1/customer-dimension-configs/defaults:
post:
summary: 幂等初始化客户尺寸计算默认配置
description: 仅供 IAM 在客户创建或注册成功编排中调用;同一客户和 initializationKey 重试稳定返回 createdByThisInitialization=true。
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerDimensionConfigInitializeRequest'}
responses:
'200': {description: 成功或业务失败,data 返回 configId 和 createdByThisInitialization}
delete:
summary: 补偿删除本次新建且尚未修改的默认配置
description: 仅供 IAM 创建客户事务补偿使用;同时匹配 initializationKey、默认2cm和version=0才物理删除。
security:
- internalServiceToken: []
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: initializationKey, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
responses:
'200': {description: 幂等补偿完成}
/api/platform/v1/first-leg/customer-dimension-config:
get:
summary: 查询客户尺寸计算配置
description: 需要 firstleg:dimension-config:view 权限;配置只描述真实尺寸的每侧加宽值,当前不参与订单或计费。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功时 data 为 CustomerDimensionConfigResponse}
'403': {description: 无查询权限}
put:
summary: 编辑客户尺寸计算配置
description: 需要 firstleg:dimension-config:edit 权限;使用 version 执行乐观锁更新。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerDimensionConfigUpdateRequest'}
responses:
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigResponse}
'403': {description: 无编辑权限}
/internal/firstleg/v1/inbounds/order-approved:
post:
summary: 订单审核通过后幂等创建入库单
@@ -358,6 +403,43 @@ components:
in: query
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
schemas:
CustomerDimensionConfigUpdateRequest:
type: object
required: [edgeAdditionCm, version]
properties:
edgeAdditionCm:
type: number
minimum: 0.001
maximum: 100
multipleOf: 0.001
description: 真实长、宽、高三个方向分别使用的每侧加宽厘米数
example: 2.000
remark: {type: string, maxLength: 500, nullable: true}
version: {type: integer, minimum: 0, description: 乐观锁版本号}
CustomerDimensionConfigResponse:
type: object
required: [id, tenantId, edgeAdditionCm, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
edgeAdditionCm: {type: number, minimum: 0.001, maximum: 100, example: 2.000}
remark: {type: string, nullable: true}
version: {type: integer, minimum: 0}
createdAt: {type: string, format: date-time, nullable: true}
updatedAt: {type: string, format: date-time, nullable: true}
CustomerDimensionConfigInitializeRequest:
type: object
required: [tenantId, operatorId, initializationKey]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
operatorId: {type: string, pattern: '^[0-9]+$'}
initializationKey: {type: string, minLength: 1, maxLength: 128}
CustomerDimensionConfigInitializeResponse:
type: object
required: [configId, createdByThisInitialization]
properties:
configId: {type: string, pattern: '^[1-9][0-9]*$'}
createdByThisInitialization: {type: boolean}
ConfirmWeightResultRequest:
type: object
required: [version]
@@ -0,0 +1,62 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalCustomerDimensionConfigController.class)
class CustomerDimensionConfigControllerContractTest {
@Resource
private InternalCustomerDimensionConfigController internalController;
@MockBean
private CustomerDimensionConfigService service;
@BeforeEach
void initializeToken() {
ReflectionTestUtils.setField(internalController, "internalToken", "test-token");
internalController.initializeToken();
}
@Test
void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1");
when(service.initializeDefault(8L, 100L, "registration-1"))
.thenReturn(new InitializeResponse(91L, true));
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
.isInstanceOf(BizException.class);
assertThat(internalController.initializeDefault("test-token", request).data())
.isEqualTo(new InitializeResponse(91L, true));
verify(service).initializeDefault(8L, 100L, "registration-1");
}
@Test
void platformControllerKeepsScopeAndExistingPermissions() throws Exception {
RequestMapping mapping = PlatformCustomerDimensionConfigController.class
.getAnnotation(RequestMapping.class);
assertThat(mapping.value()).containsExactly("/api/platform/v1/first-leg/customer-dimension-config");
assertThat(PlatformCustomerDimensionConfigController.class
.getDeclaredMethod("get", com.baiyue.common.security.AuthenticatedUser.class, long.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:view')");
assertThat(PlatformCustomerDimensionConfigController.class
.getDeclaredMethod("update", com.baiyue.common.security.AuthenticatedUser.class,
long.class, com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:edit')");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.math.BigDecimal;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class ConfirmWeightResultRequestValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void acceptsConfirmationWithoutAdjustment() {
var request = new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, null, null, null);
assertThat(validator.validate(request)).isEmpty();
assertThat(request.hasAdjustment()).isFalse();
}
@Test
void acceptsDocumentedWeightBoundaries() {
var minimum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("0.001"), "KG", "最小值");
var maximum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
Integer.MAX_VALUE, new BigDecimal("999999999999999.999"), "LB", "最大值");
assertThat(validator.validate(minimum)).isEmpty();
assertThat(validator.validate(maximum)).isEmpty();
assertThat(minimum.hasAdjustment()).isTrue();
assertThat(maximum.hasAdjustment()).isTrue();
}
@ParameterizedTest
@MethodSource("invalidRequests")
void rejectsInvalidFieldBoundaries(
InboundMeasurementRequests.ConfirmWeightResultRequest request,
String invalidField) {
assertThat(validator.validate(request))
.extracting(violation -> violation.getPropertyPath().toString())
.contains(invalidField);
}
private static Stream<Arguments> invalidRequests() {
return Stream.of(
Arguments.of(request(-1, "1.000", "KG", "原因"), "version"),
Arguments.of(request(0, "0.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "-1.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1.0001", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1000000000000000.000", "KG", "原因"), "adjustedWeightValue"),
Arguments.of(request(0, "1.000", "KILOGRAMS", "原因"), "adjustedWeightUnit"),
Arguments.of(request(0, "1.000", "KG", "x".repeat(501)), "adjustmentReason")
);
}
private static InboundMeasurementRequests.ConfirmWeightResultRequest request(
int version, String value, String unit, String reason) {
return new InboundMeasurementRequests.ConfirmWeightResultRequest(
version, new BigDecimal(value), unit, reason);
}
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Delete;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionConfigSqlContractTest {
@Test
void moduleMigrationDefinesUniqueCustomerConfigAndDefault() throws IOException {
String sql = new ClassPathResource("db/customer-dimension-config.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_customer_dimension_config")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
}
@Test
void unifiedMigrationIncludesConfigAndFinalAssertion() throws IOException {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config")
.contains("客户尺寸计算配置表未创建")
.contains("uk_customer_dimension_config_tenant")
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
.contains("id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view'")
.contains("permission_code = 'firstleg:dimension-config:view' AND id <> 5102111")
.contains("id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit'")
.contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("r.role_code = 'PLATFORM_ADMIN'")
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("客户尺寸计算配置权限父节点企业客户不存在")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确")
.contains("平台管理员未获得客户尺寸计算配置读写权限")
.contains("订单运营角色的客户尺寸计算配置权限不是只读");
}
@Test
void compensationDeleteRequiresExactKeyDefaultValueAndUnmodifiedVersion() throws Exception {
Delete annotation = com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper.class
.getDeclaredMethod("deleteUnchangedDefault", long.class, long.class,
String.class, java.math.BigDecimal.class)
.getAnnotation(Delete.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("tenant_id = #{tenantId}")
.contains("initialization_key = #{initializationKey}")
.contains("edge_addition_cm = #{edgeAdditionCm}")
.contains("version = 0");
}
}
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerDimensionConfigService.class)
class CustomerDimensionConfigServiceTest {
@MockBean
private CustomerDimensionConfigRepository repository;
@Resource
private CustomerDimensionConfigService service;
@Test
void initializesTwoCentimeterDefaultExactlyOnce() {
CustomerDimensionConfigEntity persisted = config(91L, 8L, "2.000", 0, "registration-1");
when(repository.find(8L)).thenReturn(null, persisted);
when(repository.insertDefault(anyLong(), eq(8L), eq(new BigDecimal("2.000")),
eq("默认每侧加宽 2cm"), eq("registration-1"), eq(100L))).thenReturn(true);
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result.createdByThisInitialization()).isTrue();
assertThat(result.configId()).isEqualTo(91L);
}
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-1"), anyLong());
}
@Test
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-2"), anyLong());
}
@Test
void rejectsStaleUpdateVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 3, "registration-1"));
assertThatThrownBy(() -> service.update(8L, 100L,
new UpdateRequest(new BigDecimal("2.500"), "新配置", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
verify(repository, never()).update(org.mockito.ArgumentMatchers.any());
}
@Test
void compensationUsesTenantKeyDefaultValueAndUnmodifiedVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
}
@Test
void editedConfigIsNotCompensatedByDelayedRollback() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.500", 1, "registration-1"));
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
}
private static CustomerDimensionConfigEntity config(long id, long tenantId,
String edgeAddition, int version,
String initializationKey) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(id);
value.setTenantId(tenantId);
value.setEdgeAdditionCm(new BigDecimal(edgeAddition));
value.setVersion(version);
value.setInitializationKey(initializationKey);
return value;
}
}
@@ -13,6 +13,8 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -123,6 +125,107 @@ class InboundMeasurementServiceTest {
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
}
@ParameterizedTest
@CsvSource({
"kg,2.000",
"KGS,2.000",
"lb,0.907",
"LBS,0.907",
"' lbs ',0.907"
})
void confirmingAcceptsSupportedUnitAliases(String unit, String expectedKg) {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "1.000", "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("2.000"), unit, "单位边界测试"));
assertThat(response.adjustedWeightUnit()).isEqualTo(
unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG");
assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg);
assertThat(response.version()).isEqualTo(2);
}
@Test
void confirmingAcceptsSmallestRepresentableKgWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "0.001", "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, null, null, null));
assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001");
}
@Test
void confirmingRejectsLbWeightThatRoundsToZeroKgBeforePersisting() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "1.000", "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("0.001"), "LB", "极小重量边界测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("换算后重量不能小于 0.001 KG");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingRejectsOcrLbWeightThatRoundsToZeroKg() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundWeightResultEntity weight = weight(20L, "0.001", "LB", "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, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("换算后重量不能小于 0.001 KG");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingRejectsUnsupportedUnitBeforePersisting() {
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"), "G", "错误单位测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("重量单位仅支持 KG 或 LB");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingAdjustedWeightWritesCorrectionAndConfirmationInOneTransaction() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -173,6 +276,87 @@ class InboundMeasurementServiceTest {
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingRejectsEveryPartialAdjustmentCombinationBeforePersisting() {
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);
List<InboundMeasurementRequests.ConfirmWeightResultRequest> partialRequests = List.of(
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, "KG", null),
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, "人工修正"),
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), " ", " ")
);
partialRequests.forEach(request -> assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员", request))
.isInstanceOf(BizException.class)
.hasMessageContaining("有效重量、单位和修改原因"));
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
InboundMeasurementResponses.WeightResult response = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(2, null, null, null));
assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(response.version()).isEqualTo(2);
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
verify(repository, never()).updateInbound(any());
}
@Test
void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
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(1, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, never()).updateWeight(any());
verify(repository, never()).insertRevision(any());
}
@Test
void confirmingAdjustedWeightStopsBeforeRecalculationWhenConfirmUpdateConflicts() {
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, false);
assertThatThrownBy(() -> service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("286.125"), "KG", "并发冲突测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, times(2)).updateWeight(weight);
verify(repository, times(1)).insertRevision(any(InboundWeightRevisionEntity.class));
verify(repository, never()).listWeights(anyLong(), anyLong());
verify(repository, never()).updateInbound(any());
}
@Test
void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -0,0 +1,29 @@
package com.baiyue.iam.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestParam;
/** Firstleg internal contract for provisioning one customer dimension rule. */
@FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient",
url = "${baiyue.clients.firstleg.url:}",
path = "/internal/firstleg/v1/customer-dimension-configs")
public interface CustomerDimensionRuleClient {
@PostMapping("/defaults")
ApiResponse<InitializationResponse> initialize(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody InitializationRequest request);
@DeleteMapping("/defaults")
ApiResponse<Void> compensate(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam long tenantId,
@RequestParam String initializationKey);
record InitializationRequest(long tenantId, long operatorId, String initializationKey) { }
record InitializationResponse(Long configId, boolean createdByThisInitialization) { }
}
@@ -0,0 +1,119 @@
package com.baiyue.iam.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.iam.client.CustomerDimensionRuleClient;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** Provisions the customer dimension rule and compensates it when local customer creation rolls back. */
@Service
public class CustomerDimensionRuleInitializationService {
private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class);
private static final int MAX_ATTEMPTS = 3;
private static final String KEY_PREFIX = "iam-customer-create:";
@Resource
private CustomerDimensionRuleClient client;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public InitializationResult initialize(long tenantId, long operatorId) {
String initializationKey = initializationKey(tenantId);
RuntimeException lastFailure = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize(
internalToken,
new CustomerDimensionRuleClient.InitializationRequest(
tenantId, operatorId, initializationKey));
if (response != null && response.successful() && response.data() != null) {
InitializationResult result = new InitializationResult(
response.data().createdByThisInitialization(), initializationKey);
registerRollbackCompensation(tenantId, result);
return result;
}
lastFailure = unavailable(response);
log.warn("客户尺寸规则初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}",
tenantId, attempt, response == null ? null : response.code(),
response == null ? null : response.requestId(),
response == null ? null : response.traceId());
} catch (RuntimeException failure) {
lastFailure = failure;
log.warn("客户尺寸规则初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure);
}
}
compensateAfterUncertainInitialization(tenantId, initializationKey);
throw unavailable(lastFailure);
}
/** Used when Auth compensates a committed client registration. */
public void compensateRegistration(long tenantId) {
compensateRequired(tenantId, initializationKey(tenantId));
}
private void registerRollbackCompensation(long tenantId, InitializationResult result) {
if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return;
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status != STATUS_ROLLED_BACK) return;
try {
compensateRequired(tenantId, result.initializationKey());
} catch (RuntimeException compensationFailure) {
log.error("客户创建事务回滚后尺寸规则补偿失败,tenantId={}, initializationKey={}",
tenantId, result.initializationKey(), compensationFailure);
}
}
});
}
private void compensateAfterUncertainInitialization(long tenantId, String initializationKey) {
try {
compensateRequired(tenantId, initializationKey);
} catch (RuntimeException compensationFailure) {
log.error("客户尺寸规则初始化结果不确定且补偿失败,tenantId={}, initializationKey={}",
tenantId, initializationKey, compensationFailure);
}
}
private void compensateRequired(long tenantId, String initializationKey) {
RuntimeException lastFailure = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
ApiResponse<Void> response = client.compensate(internalToken, tenantId, initializationKey);
if (response != null && response.successful()) return;
lastFailure = unavailable(response);
} catch (RuntimeException failure) {
lastFailure = failure;
}
}
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"客户尺寸规则补偿失败,请根据客户ID修复数据一致性: " + tenantId);
}
private static BizException unavailable(ApiResponse<?> response) {
String detail = response == null || response.message() == null || response.message().isBlank()
? "Firstleg 服务未返回有效响应" : response.message();
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "默认尺寸规则初始化失败: " + detail);
}
private static BizException unavailable(RuntimeException cause) {
String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank()
? "Firstleg 服务暂时不可用" : cause.getMessage();
return new BizException(ResultCode.SERVICE_UNAVAILABLE,
"默认尺寸规则初始化失败,已重试 3 次: " + detail);
}
private static String initializationKey(long tenantId) {
return KEY_PREFIX + tenantId;
}
public record InitializationResult(boolean created, String initializationKey) { }
}
@@ -40,6 +40,7 @@ public class MerchantOnboardingService {
@Resource private IamTenantAddressMapper addressMapper;
@Resource private IamTenantQualificationMapper qualificationMapper;
@Resource private IamTenantReviewMapper reviewMapper;
@Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Transactional
public MerchantRegistrationResponse register(MerchantRegistrationRequest request) {
@@ -118,6 +119,7 @@ public class MerchantOnboardingService {
address.setIsDefault(true);
addressMapper.insert(address);
}
dimensionRuleInitialization.initialize(tenantId, request.authUserId());
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus());
}
@@ -159,6 +161,8 @@ public class MerchantOnboardingService {
"IAM registration compensation is unsafe: onboarding has progressed");
}
dimensionRuleInitialization.compensateRegistration(tenant.getId());
membershipRoleMapper.delete(new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getMembershipId, membership.getId()));
membershipMapper.deleteById(membership.getId());
@@ -50,6 +50,8 @@ public class TenantService {
private String internalToken;
@Resource
private TenantApprovalInitializationCoordinator approvalInitialization;
@Resource
private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@BusinessAudit(
@@ -88,6 +90,7 @@ public class TenantService {
tenant.setCreatedBy(operatorId);
tenant.setUpdatedBy(operatorId);
repository.insertTenant(tenant);
dimensionRuleInitialization.initialize(tenant.getId(), operatorId);
return response(tenant);
}
@@ -0,0 +1,52 @@
-- 客户尺寸计算配置权限;仅用于平台后台查看和维护,不控制内部初始化接口。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS migrate_customer_dimension_config_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_customer_dimension_config_permissions()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE (id = 5102111 AND permission_code <> 'firstleg:dimension-config:view')
OR (id = 5102112 AND permission_code <> 'firstleg:dimension-config:edit')
OR (permission_code = 'firstleg:dimension-config:view' AND id <> 5102111)
OR (permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'customer dimension config permission id/code conflict';
END IF;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102111, 0, 'PLATFORM', 'BUTTON', 5100010,
'firstleg:dimension-config:view', '查看客户尺寸计算配置', NULL, NULL, 90, 'ACTIVE', 1, 0),
(5102112, 0, 'PLATFORM', 'BUTTON', 5100010,
'firstleg:dimension-config:edit', '维护客户尺寸计算配置', NULL, NULL, 100, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, role.tenant_id
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.app_code = 'PLATFORM'
WHERE role.tenant_id = 0
AND role.app_code = 'PLATFORM'
AND (
(role.role_code = 'PLATFORM_ADMIN'
AND permission.permission_code IN (
'firstleg:dimension-config:view', 'firstleg:dimension-config:edit'))
OR (role.role_code = 'ORDER_OPERATOR'
AND permission.permission_code = 'firstleg:dimension-config:view')
);
END$$
DELIMITER ;
CALL migrate_customer_dimension_config_permissions();
DROP PROCEDURE IF EXISTS migrate_customer_dimension_config_permissions;
@@ -0,0 +1,27 @@
package com.baiyue.iam.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionRuleClientContractTest {
@Test
void usesTheFirstlegInternalDimensionConfigContract() throws Exception {
FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class);
PostMapping initialize = CustomerDimensionRuleClient.class
.getMethod("initialize", String.class,
CustomerDimensionRuleClient.InitializationRequest.class)
.getAnnotation(PostMapping.class);
DeleteMapping compensate = CustomerDimensionRuleClient.class
.getMethod("compensate", String.class, long.class, String.class)
.getAnnotation(DeleteMapping.class);
assertThat(feign.name()).isEqualTo("firstleg-service");
assertThat(feign.path()).isEqualTo("/internal/firstleg/v1/customer-dimension-configs");
assertThat(initialize.value()).containsExactly("/defaults");
assertThat(compensate.value()).containsExactly("/defaults");
}
}
@@ -0,0 +1,40 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionConfigPermissionSqlContractTest {
@Test
void declaresIndependentPlatformViewAndEditPermissions() throws Exception {
String sql = new ClassPathResource("db/customer-dimension-config-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("'firstleg:dimension-config:view'")
.contains("'firstleg:dimension-config:edit'")
.contains("SIGNAL SQLSTATE '45000'")
.contains("id = 5102111 AND permission_code <> 'firstleg:dimension-config:view'")
.contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112");
}
@Test
void resolvesRoleGrantsByBusinessKeys() throws Exception {
String sql = new ClassPathResource("db/customer-dimension-config-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("SELECT role.id, permission.id, role.tenant_id")
.contains("role.role_code = 'PLATFORM_ADMIN'")
.contains("permission.permission_code IN (")
.contains("role.role_code = 'ORDER_OPERATOR'")
.contains("permission.permission_code = 'firstleg:dimension-config:view'")
.doesNotContain("SELECT 4000001")
.doesNotContain("SELECT 4000003");
}
}
@@ -0,0 +1,138 @@
package com.baiyue.iam.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.iam.client.CustomerDimensionRuleClient;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class CustomerDimensionRuleInitializationServiceTest {
@MockBean private CustomerDimensionRuleClient client;
@Resource private CustomerDimensionRuleInitializationService service;
@AfterEach
void clearSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void retriesWithOneStableKeyAndReturnsTheIdempotentResult() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("timeout"))
.thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE,
"temporarily unavailable", "request-1", "trace-1"))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false)));
var result = service.initialize(10001L, 9001L);
assertFalse(result.created());
ArgumentCaptor<CustomerDimensionRuleClient.InitializationRequest> requests =
ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class);
verify(client, times(3)).initialize(anyString(), requests.capture());
assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001",
"iam-customer-create:10001"),
requests.getAllValues().stream().map(
CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList());
assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream()
.map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList());
assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream()
.map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList());
}
@Test
void reportsServiceUnavailableAfterThreeFailedAttemptsAndTriesCompensation() {
when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout"));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode());
verify(client, times(3)).initialize(anyString(), any());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void localRollbackCompensatesOnlyAConfigurationCreatedByThisRegistration() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
var result = service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
assertTrue(result.created());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void localRollbackDoesNotDeleteAConfigurationThatAlreadyExisted() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false)));
TransactionSynchronizationManager.initSynchronization();
service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(client, never()).compensate(anyString(), anyLong(), anyString());
}
@Test
void responseTimeoutThenIdempotentSuccessStillCompensatesOnLocalRollback() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("response timeout"))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
service.initialize(10001L, 9001L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
verify(client, times(2)).initialize(anyString(), any());
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
@Test
void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() {
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
service.compensateRegistration(10001L);
verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001");
}
}
@@ -39,6 +39,7 @@ class MerchantOnboardingServiceTest {
@MockBean private IamTenantAddressMapper addresses;
@MockBean private IamTenantQualificationMapper qualifications;
@MockBean private IamTenantReviewMapper reviews;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource private MerchantOnboardingService service;
@Test
void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() {
@@ -63,6 +64,7 @@ class MerchantOnboardingServiceTest {
verify(users).insert(user.capture());
verify(memberships).insert(membership.capture());
verify(membershipRoles).insert(roleGrant.capture());
verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L);
assertEquals("PROFILE_REQUIRED", result.tenantStatus());
assertEquals(result.tenantId(), tenant.getValue().getId());
@@ -104,6 +106,7 @@ class MerchantOnboardingServiceTest {
service.compensateRegistration(9001L);
verify(dimensionRuleInitialization).compensateRegistration(10001L);
verify(membershipRoles).delete(any());
verify(memberships).deleteById(30001L);
verify(addresses).delete(any());
@@ -111,6 +114,28 @@ class MerchantOnboardingServiceTest {
verify(tenants).deleteById((java.io.Serializable) Long.valueOf(10001L));
}
@Test
void clientRegistrationFailsWhenDefaultDimensionRuleCannotBeInitialized() {
IamRole ownerRole = new IamRole();
ownerRole.setId(4000100L);
ownerRole.setTenantId(0L);
ownerRole.setAppCode("MERCHANT");
ownerRole.setRoleCode("MERCHANT_OWNER");
ownerRole.setStatus("ACTIVE");
when(roles.selectOne(any())).thenReturn(ownerRole);
org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "初始化失败"))
.when(dimensionRuleInitialization).initialize(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong());
var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
() -> service.register(new MerchantRegistrationRequest(
9001L, "注册人", "测试企业", "张三", "13800138000",
"owner@example.com", "CN", "深圳市宝安区")));
assertEquals(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode());
}
@Test
void compensationRefusesAfterQualificationUpload() {
@@ -136,4 +161,34 @@ class MerchantOnboardingServiceTest {
verify(users, never()).deleteById((java.io.Serializable) any());
verify(tenants, never()).deleteById((java.io.Serializable) any());
}
@Test
void compensationKeepsIamRegistrationWhenDimensionRuleCompensationFails() {
IamUser user = new IamUser();
user.setId(20001L);
user.setAuthUserId(9001L);
IamMembership membership = new IamMembership();
membership.setId(30001L);
membership.setTenantId(10001L);
membership.setUserId(20001L);
membership.setMemberType("OWNER");
IamTenant tenant = new IamTenant();
tenant.setId(10001L);
tenant.setStatus("PROFILE_REQUIRED");
when(users.selectOne(any())).thenReturn(user);
when(memberships.selectList(any())).thenReturn(java.util.List.of(membership));
when(tenants.selectOne(any())).thenReturn(tenant);
when(qualifications.selectCount(any())).thenReturn(0L);
when(reviews.selectCount(any())).thenReturn(0L);
org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "补偿失败"))
.when(dimensionRuleInitialization).compensateRegistration(10001L);
assertThrows(com.baiyue.common.core.exception.BizException.class,
() -> service.compensateRegistration(9001L));
verify(membershipRoles, never()).delete(any());
verify(users, never()).deleteById((java.io.Serializable) any());
verify(tenants, never()).deleteById((java.io.Serializable) any());
}
}
@@ -23,6 +23,7 @@ class TenantCustomerExportTest {
@MockBean private ProfileResourceRepository profileRepository;
@MockBean private AuthAccountClient authAccountClient;
@MockBean private TenantApprovalInitializationCoordinator approvalInitialization;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource private TenantService service;
@Test
void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
@@ -48,6 +48,7 @@ class TenantServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient authClient;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@SpyBean private TenantAdministratorProvisioningService provisioning;
@Resource private TenantService service;
@@ -177,9 +178,23 @@ class TenantServiceTest {
assertEquals("基础版", created.servicePackage());
verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy())
&& Long.valueOf(9001L).equals(tenant.getUpdatedBy())));
verify(dimensionRuleInitialization).initialize(created.id(), 9001L);
verify(repository, never()).insertReview(any());
}
@Test
void platformRegistrationFailsWhenDefaultDimensionRuleCannotBeInitialized() {
doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败"))
.when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class));
BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode());
}
@Test
void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() {
@@ -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 {