diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ec3695119..00602650d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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(); diff --git a/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java index 1d73d2fb0..b6aef99dc 100644 --- a/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java +++ b/file/src/main/java/com/baiyue/file/service/ProductImageStorage.java @@ -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 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) { diff --git a/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java new file mode 100644 index 000000000..d9828b371 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/ProductImageControllerContractTest.java @@ -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()); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java b/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java new file mode 100644 index 000000000..07e6d728a --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ProductImageStorageTest.java @@ -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(); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java new file mode 100644 index 000000000..70df7e8aa --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java @@ -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 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 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, "内部服务凭证无效"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java new file mode 100644 index 000000000..3a9992596 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java @@ -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 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 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java new file mode 100644 index 000000000..fc4d3f934 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java @@ -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) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java new file mode 100644 index 000000000..0fe4f75ca --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java new file mode 100644 index 000000000..cc2116713 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java @@ -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 { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java new file mode 100644 index 000000000..45c8aea1d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java @@ -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() + .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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java new file mode 100644 index 000000000..f86f8e9d8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java @@ -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(); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 5cea4873d..75f16ace6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -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(); diff --git a/firstleg/src/main/resources/db/customer-dimension-config.sql b/firstleg/src/main/resources/db/customer-dimension-config.sql new file mode 100644 index 000000000..1fe38ac7c --- /dev/null +++ b/firstleg/src/main/resources/db/customer-dimension-config.sql @@ -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='客户尺寸计算配置'; diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index a00d14c07..833babad3 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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='客户尺寸计算配置'; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 258260170..a3a9cd913 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java new file mode 100644 index 000000000..d6bb1c5fc --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java @@ -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')"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dto/ConfirmWeightResultRequestValidationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dto/ConfirmWeightResultRequestValidationTest.java new file mode 100644 index 000000000..a63ac7287 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dto/ConfirmWeightResultRequestValidationTest.java @@ -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 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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java new file mode 100644 index 000000000..1b9784e60 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java new file mode 100644 index 000000000..f8d28d1c8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java @@ -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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index fb480d621..6a788f274 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -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 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"); diff --git a/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java new file mode 100644 index 000000000..ec6a148f9 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java @@ -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 initialize( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody InitializationRequest request); + + @DeleteMapping("/defaults") + ApiResponse 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) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java new file mode 100644 index 000000000..050ae2305 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -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 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 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) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 59e78e689..84fb530ee 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -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() .eq(IamMembershipRole::getMembershipId, membership.getId())); membershipMapper.deleteById(membership.getId()); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 56ba60b3e..22329a090 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -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); } diff --git a/iam/src/main/resources/db/customer-dimension-config-permissions.sql b/iam/src/main/resources/db/customer-dimension-config-permissions.sql new file mode 100644 index 000000000..af6bfbef1 --- /dev/null +++ b/iam/src/main/resources/db/customer-dimension-config-permissions.sql @@ -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; diff --git a/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java new file mode 100644 index 000000000..f6a0a355e --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java @@ -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"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java new file mode 100644 index 000000000..a5106e2f4 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionConfigPermissionSqlContractTest.java @@ -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"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java new file mode 100644 index 000000000..cdf4d9164 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -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 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 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 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 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"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 59644c2f1..7b2071e9d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -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()); + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index 91f10ac3f..9cc3b8ef1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -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() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 3158de236..cd244dca3 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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() { diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 869d6e2b8..2ca523c5d 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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."); } } } diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql index 426311429..acbbde30f 100644 --- a/order/src/main/resources/db/phase1-order-extension.sql +++ b/order/src/main/resources/db/phase1-order-extension.sql @@ -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; -- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。 diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index ab2b6ef44..6f2cce29a 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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: diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 31f02569c..2550e1d95 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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) diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index 38858ccba..f56372c80 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -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) diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java index 0604383e6..10e802c31 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -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 { diff --git a/order/src/test/java/com/baiyue/order/service/ProductImageGuardTest.java b/order/src/test/java/com/baiyue/order/service/ProductImageGuardTest.java new file mode 100644 index 000000000..ab6cbf4e6 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/ProductImageGuardTest.java @@ -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); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java index bc1445931..e8684f703 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -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 {