优化
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
@@ -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, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+102
@@ -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]
|
||||
|
||||
+62
@@ -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')");
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+65
@@ -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");
|
||||
}
|
||||
}
|
||||
+109
@@ -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;
|
||||
}
|
||||
}
|
||||
+184
@@ -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) { }
|
||||
}
|
||||
+119
@@ -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");
|
||||
}
|
||||
}
|
||||
+40
@@ -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");
|
||||
}
|
||||
}
|
||||
+138
@@ -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;
|
||||
|
||||
-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user