diff --git a/AGENTS.md b/AGENTS.md index 19c07a38b..b4d191662 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ | `iam/` | 租户、商户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` | | `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照、货物明细内部查询与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`InternalOrderCargoController.java`、`README-ORDER.md` | | `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` | -| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` | +| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司、海外仓阶梯费用;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`、`PlatformOverseasStorageFeeController.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` | | `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` | | `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` | | `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 | @@ -39,6 +39,7 @@ | 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` | | 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` | | 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` | +| 海外仓阶梯费用 | `warehouse/.../controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;表为 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` | | 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway | | HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` | | 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` | @@ -56,7 +57,7 @@ | 托盘、库位与预占/占用 | 平台 CRUD:`warehouse/.../controller/PlatformPalletController.java`、`PlatformLocationController.java`;内部事务接口:`InternalPalletLocationController.java` | `service/PalletApplicationService.java`、`LocationApplicationService.java`、`PalletLocationAllocationService.java`;表与迁移在 `warehouse/src/main/resources/db/phase2-*.sql` | | 称重图片、OCR 重试与第三方测量证据 | 平台入口 `file/.../controller/PlatformMeasurementImageController.java`;Firstleg 内部入口 `InternalMeasurementImageController.java` | `service/MeasurementImageService.java`、`MeasurementImageStorage.java`、`client/WeightOcrClient.java`;重量识别服务入口 `integration-service/.../controller/WeightOcrController.java` | | 入库状态、幂等、历史与 Outbox | `firstleg/.../domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java`、`repository/InboundWorkflowRepository.java` | 表结构与增量在 `firstleg/src/main/resources/db/phase2-*.sql`;SQL 契约测试 `repository/InboundWorkflowSqlContractTest.java` | -| 二期数据库部署 | 测试环境/存量环境统一入口 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` | 覆盖 `baiyue_file`、`baiyue_firstleg`、`baiyue_warehouse`;前置、可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,不得在未核对目标库和备份的情况下执行 | +| 数据库部署 | 空库/存量环境唯一入口 `deploy/sql/头程数据库统一增量-MySQL8.sql` | 覆盖全部一期、二期及后续增量;可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,历史日期脚本不得再单独执行 | - 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue//` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。 - 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。 diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java index acc5669a4..07bcb9bfd 100644 --- a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java +++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java @@ -1,5 +1,7 @@ package com.baiyue.audit.repository; +import jakarta.annotation.Resource; + import com.baiyue.audit.dto.AuditIngestRequest; import com.baiyue.audit.entity.AuditLogEntity; import com.baiyue.audit.mapper.AuditLogMapper; @@ -10,11 +12,8 @@ import org.springframework.stereotype.Repository; */ @Repository public class AuditEventRepository { - private final AuditLogMapper mapper; - - public AuditEventRepository(AuditLogMapper mapper) { - this.mapper = mapper; - } + @Resource + private AuditLogMapper mapper; public void save(AuditIngestRequest event) { AuditLogEntity entity = new AuditLogEntity(); diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml index d7ab608c6..db601f1db 100644 --- a/audit-service/src/main/resources/application.yml +++ b/audit-service/src/main/resources/application.yml @@ -35,6 +35,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java index df0266849..6cdc1e02f 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java @@ -1,5 +1,8 @@ package com.baiyue.auth.client; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.auth.entity.AuthAccount; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -12,16 +15,16 @@ import org.springframework.stereotype.Component; */ @Component public class AuthorizationLoader { - private final IamAuthorizationClient client; - private final String internalToken; + @Resource + private IamAuthorizationClient client; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; - public AuthorizationLoader(IamAuthorizationClient client, - @Value("${baiyue.security.internal-service-token}") String internalToken) { + @PostConstruct + void validateConfiguration() { if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); } - this.client = client; - this.internalToken = internalToken; } public AuthorizationSnapshot load(AuthAccount account) { diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index cb51fad93..0494b1431 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -21,14 +21,10 @@ public class InternalAccountController { @Resource private AccountManagementService service; - private final String configuredToken; + @Value("${baiyue.security.internal-service-token}") + private String configuredToken; private byte[] tokenBytes; - public InternalAccountController( - @Value("${baiyue.security.internal-service-token}") String configuredToken) { - this.configuredToken = configuredToken; - } - @PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); } @PostMapping diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java index 31551dda9..a969f6f31 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java @@ -1,5 +1,7 @@ package com.baiyue.auth.repository; +import jakarta.annotation.Resource; + import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.mapper.AuthAccountMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -10,11 +12,8 @@ import org.springframework.stereotype.Repository; */ @Repository public class AuthAccountRepository { - private final AuthAccountMapper mapper; - - public AuthAccountRepository(AuthAccountMapper mapper) { - this.mapper = mapper; - } + @Resource + private AuthAccountMapper mapper; public AuthAccount findByUsername(String username) { return mapper.selectOne(new LambdaQueryWrapper() diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java index 8f93c0d71..1e0096883 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthRefreshTokenRepository.java @@ -1,5 +1,7 @@ package com.baiyue.auth.repository; +import jakarta.annotation.Resource; + import com.baiyue.auth.entity.AuthRefreshToken; import com.baiyue.auth.mapper.AuthRefreshTokenMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -10,11 +12,8 @@ import org.springframework.stereotype.Repository; */ @Repository public class AuthRefreshTokenRepository { - private final AuthRefreshTokenMapper mapper; - - public AuthRefreshTokenRepository(AuthRefreshTokenMapper mapper) { - this.mapper = mapper; - } + @Resource + private AuthRefreshTokenMapper mapper; public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) { String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"; diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index 2a7a75953..254839ee1 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -1,10 +1,14 @@ package com.baiyue.auth.service; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.RedisTokenKeys; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.SetOperations; @@ -25,15 +29,16 @@ import java.util.List; */ @Component public class TokenSessionStore { - private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - private final String keyPrefix; + @Autowired + private StringRedisTemplate redisTemplate; + @Resource + private ObjectMapper objectMapper; + @Value("${baiyue.security.token-key-prefix:baiyue:auth}") + private String keyPrefix; - public TokenSessionStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, - @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { - this.redisTemplate = redisTemplate; - this.objectMapper = objectMapper; - this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); + @PostConstruct + void normalizeKeyPrefix() { + keyPrefix = RedisTokenKeys.normalize(keyPrefix); } public void store(String jwtId, Long userId, String rawRefreshToken, diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index e67599e64..3c9e463aa 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -52,6 +52,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index 161404945..a486c65c7 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -11,25 +11,29 @@ import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import java.time.LocalDateTime; @DisplayName("账号管理服务") +@SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class}) class AccountManagementServiceTest { - private final AuthAccountRepository repository = mock(AuthAccountRepository.class); - private final TokenSessionStore sessions = mock(TokenSessionStore.class); - private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4); - private final AccountManagementService service = new AccountManagementService(); + @MockBean private AuthAccountRepository repository; + @MockBean private TokenSessionStore sessions; + @Resource private PasswordEncoder encoder; + @Resource private AccountManagementService service; private AuthAccount account; @BeforeEach void setUp() { - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "passwordEncoder", encoder); - ReflectionTestUtils.setField(service, "tokenSessionStore", sessions); account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1")); account.setTokenVersion(2); account.setAccountStatus("ACTIVE"); when(repository.findById(10L)).thenReturn(account); @@ -123,4 +127,11 @@ class AccountManagementServiceTest { verify(repository).save(account); verify(sessions).revokeAll(10L); } + @Configuration + static class TestConfig { + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(4); + } + } } diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index 61ce317ec..5a4541699 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -7,31 +7,32 @@ import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.common.core.api.ApiResponse; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; +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 jakarta.annotation.Resource; + import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(MerchantRegistrationService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MerchantRegistrationServiceTest { + @MockBean private AuthAccountRepository accounts; + @MockBean private PasswordEncoder encoder; + @MockBean private IamOnboardingClient onboarding; + @Resource private MerchantRegistrationService service; @Test void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() { - AuthAccountRepository accounts = mock(AuthAccountRepository.class); - PasswordEncoder encoder = mock(PasswordEncoder.class); - IamOnboardingClient onboarding = mock(IamOnboardingClient.class); - MerchantRegistrationService service = new MerchantRegistrationService(); - ReflectionTestUtils.setField(service, "accountRepository", accounts); - ReflectionTestUtils.setField(service, "passwordEncoder", encoder); - ReflectionTestUtils.setField(service, "onboardingClient", onboarding); - ReflectionTestUtils.setField(service, "internalToken", "internal-token"); when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); @@ -59,14 +60,6 @@ class MerchantRegistrationServiceTest { @Test void failedAuthInsertCompensatesTheIamRegistration() { - AuthAccountRepository accounts = mock(AuthAccountRepository.class); - PasswordEncoder encoder = mock(PasswordEncoder.class); - IamOnboardingClient onboarding = mock(IamOnboardingClient.class); - MerchantRegistrationService service = new MerchantRegistrationService(); - ReflectionTestUtils.setField(service, "accountRepository", accounts); - ReflectionTestUtils.setField(service, "passwordEncoder", encoder); - ReflectionTestUtils.setField(service, "onboardingClient", onboarding); - ReflectionTestUtils.setField(service, "internalToken", "internal-token"); when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); @@ -87,14 +80,6 @@ class MerchantRegistrationServiceTest { @Test void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() { - AuthAccountRepository accounts = mock(AuthAccountRepository.class); - PasswordEncoder encoder = mock(PasswordEncoder.class); - IamOnboardingClient onboarding = mock(IamOnboardingClient.class); - MerchantRegistrationService service = new MerchantRegistrationService(); - ReflectionTestUtils.setField(service, "accountRepository", accounts); - ReflectionTestUtils.setField(service, "passwordEncoder", encoder); - ReflectionTestUtils.setField(service, "onboardingClient", onboarding); - ReflectionTestUtils.setField(service, "internalToken", "internal-token"); when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java index 0d53e121b..35a6f8c18 100644 --- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -3,9 +3,15 @@ package com.baiyue.auth.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.SetOperations; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.Duration; import java.util.Set; @@ -18,14 +24,27 @@ import static org.mockito.Mockito.when; import static org.mockito.Mockito.eq; @DisplayName("令牌会话存储") +@SpringJUnitConfig(classes = {TokenSessionStore.class, TokenSessionStoreTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.security.token-key-prefix=baiyue:auth") class TokenSessionStoreTest { + @MockBean + private StringRedisTemplate redis; + + @Resource + private TokenSessionStore store; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } @Test void storesAccessAndHashedRefreshWithIndependentTtls() { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); - TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth:"); store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); @@ -36,14 +55,12 @@ class TokenSessionStoreTest { @Test void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); String json = new ObjectMapper().writeValueAsString( new TokenSessionStore.RefreshSession(42L, "jti-1")); when(values.getAndDelete(anyString())).thenReturn(json); - TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth"); TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); @@ -55,26 +72,23 @@ class TokenSessionStoreTest { @Test void treatsAlreadyConsumedRefreshAsInvalid() { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); when(values.getAndDelete(anyString())).thenReturn(null); - assertThat(new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth") - .consume("raw-refresh")).isNull(); + assertThat(store.consume("raw-refresh")).isNull(); } @Test void revokesAllIndexedSessionsForUser() { - StringRedisTemplate redis = mock(StringRedisTemplate.class); @SuppressWarnings("unchecked") SetOperations sets = mock(SetOperations.class); when(redis.opsForSet()).thenReturn(sets); when(sets.members("baiyue:auth:user-sessions:42")) .thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")); - new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth").revokeAll(42L); + store.revokeAll(42L); verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"))); verify(redis).delete("baiyue:auth:user-sessions:42"); diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BaiyueAuditAutoConfiguration.java b/common-audit/src/main/java/com/baiyue/common/audit/BaiyueAuditAutoConfiguration.java index 367266286..5dcfce108 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/BaiyueAuditAutoConfiguration.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/BaiyueAuditAutoConfiguration.java @@ -19,5 +19,5 @@ public class BaiyueAuditAutoConfiguration { return p.getEndpoint() == null || p.getEndpoint().isBlank() ? new LoggingAuditSink() : new HttpAuditSink(builder, p.getEndpoint()); } @Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); } - @Bean BusinessAuditAspect businessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, AuditProperties p) { return new BusinessAuditAspect(sink, sanitizer, p.getServiceName()); } + @Bean BusinessAuditAspect businessAuditAspect() { return new BusinessAuditAspect(); } } diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java index cb1136966..dd1d190f5 100644 --- a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java +++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java @@ -1,8 +1,11 @@ package com.baiyue.common.audit; +import jakarta.annotation.Resource; + import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.web.CorrelationContext; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; @@ -27,15 +30,14 @@ import java.util.Arrays; @Aspect public class BusinessAuditAspect { private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class); - private final AuditSink sink; - private final SensitiveDataSanitizer sanitizer; - private final String serviceName; + @Resource + private AuditSink sink; + @Resource + private SensitiveDataSanitizer sanitizer; + @Value("${baiyue.audit.service-name:unknown-service}") + private String serviceName; private final ExpressionParser parser = new SpelExpressionParser(); - public BusinessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, String serviceName) { - this.sink = sink; this.sanitizer = sanitizer; this.serviceName = serviceName; - } - @Around("@annotation(audit)") public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable { long start = System.nanoTime(); diff --git a/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql b/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql deleted file mode 100644 index f33a89241..000000000 --- a/deploy/sql/2026-07-31一期回归测试环境增量迁移.sql +++ /dev/null @@ -1,930 +0,0 @@ --- 一期回归测试环境增量迁移汇总 --- 适用版本:MySQL 8.0 --- 涉及数据库:baiyue_iam、baiyue_warehouse、baiyue_order、baiyue_firstleg、baiyue_file --- 安全边界:不创建数据库、不清表、不删除业务数据、不写入演示租户及演示业务数据。 --- 可重复执行:所有新增字段、表、索引和权限均带存在性判断或幂等写入。 --- 重要说明:MySQL DDL 会隐式提交,本脚本不能作为一个跨库事务整体回滚。 - -SET NAMES utf8mb4; - --- ============================================================================ --- 1. IAM:权限资源结构、平台/商户按钮权限及管理员授权 --- ============================================================================ -USE baiyue_iam; - -DROP PROCEDURE IF EXISTS regression_assert_table; -DELIMITER $$ -CREATE PROCEDURE regression_assert_table(IN p_table_name VARCHAR(64)) -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = DATABASE() - AND table_name = p_table_name - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'baiyue_iam 缺少基础表,请先执行 IAM 基线建表脚本'; - END IF; -END$$ -DELIMITER ; - -CALL regression_assert_table('iam_permission'); -CALL regression_assert_table('iam_role'); -CALL regression_assert_table('iam_role_permission'); -CALL regression_assert_table('iam_tenant'); -CALL regression_assert_table('iam_tenant_review'); -CALL regression_assert_table('iam_tenant_qualification'); -CALL regression_assert_table('iam_membership'); -DROP PROCEDURE regression_assert_table; - -ALTER TABLE iam_tenant - MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' - COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED'; - -SET @iam_permission_built_in_sql = IF( - EXISTS( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'iam_permission' - AND column_name = 'built_in' - ), - 'SELECT 1', - 'ALTER TABLE iam_permission ADD COLUMN built_in TINYINT NOT NULL DEFAULT 0 COMMENT ''是否为系统预置权限;预置资源不可通过管理接口修改'' AFTER status' -); -PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql; -EXECUTE iam_permission_built_in_stmt; -DEALLOCATE PREPARE iam_permission_built_in_stmt; - --- 审核通过自动开户依赖全局企业管理员模板。通过自然键幂等补齐,避免存量环境 --- 已存在同编码但 ID 不同的角色时,把权限错误授予固定 ID。 -INSERT INTO iam_role ( - id, tenant_id, app_code, role_code, role_name, role_type, status, - description, created_by, updated_by, deleted, version -) VALUES ( - 4000100, 0, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', - '企业审核通过后自动创建的管理员角色模板', 1000001, 1000001, 0, 0 -) -ON DUPLICATE KEY UPDATE - role_name = VALUES(role_name), role_type = 'SYSTEM', status = 'ACTIVE', - description = VALUES(description), deleted = 0; - --- 从一期商户权限基线复制全局模板;不创建演示租户或业务数据。 -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 -) -SELECT permission.id + 100000, 0, permission.app_code, permission.permission_type, - CASE WHEN permission.parent_id = 0 THEN 0 ELSE permission.parent_id + 100000 END, - permission.permission_code, permission.permission_name, permission.route_path, - permission.http_method, permission.sort_no, 'ACTIVE', 1, 0 -FROM iam_permission permission -WHERE permission.tenant_id = 10001 AND permission.app_code = 'MERCHANT' - AND permission.permission_type IN ('MENU', 'BUTTON') - AND permission.deleted = 0 -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = VALUES(http_method), sort_no = VALUES(sort_no), - status = 'ACTIVE', built_in = 1, deleted = 0; - -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT owner_role.id, permission.id, 0 -FROM iam_role owner_role -JOIN iam_permission permission - ON permission.tenant_id = 0 AND permission.app_code = 'MERCHANT' - AND permission.permission_type IN ('MENU', 'BUTTON') - AND permission.status = 'ACTIVE' AND permission.deleted = 0 -WHERE owner_role.tenant_id = 0 AND owner_role.app_code = 'MERCHANT' - AND owner_role.role_code = 'MERCHANT_OWNER' - AND owner_role.status = 'ACTIVE' AND owner_role.deleted = 0 -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - --- 一期客户档案字段;与 iam/db/phase1-customer-profile.sql 保持一致。 -DROP PROCEDURE IF EXISTS regression_add_iam_column; -DELIMITER $$ -CREATE PROCEDURE regression_add_iam_column( - IN p_column_name VARCHAR(64), - IN p_definition VARCHAR(1000) -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'iam_tenant' - AND column_name = p_column_name - ) THEN - SET @regression_iam_sql = CONCAT( - 'ALTER TABLE iam_tenant ADD COLUMN `', p_column_name, '` ', p_definition - ); - PREPARE regression_iam_stmt FROM @regression_iam_sql; - EXECUTE regression_iam_stmt; - DEALLOCATE PREPARE regression_iam_stmt; - END IF; -END$$ -DELIMITER ; - -CALL regression_add_iam_column('contact_wechat', 'VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email'); -CALL regression_add_iam_column('legal_representative', 'VARCHAR(64) NULL COMMENT ''法定代表人'' AFTER legal_name'); -CALL regression_add_iam_column('sales_owner_name', 'VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat'); -CALL regression_add_iam_column('payment_term_days', 'SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name'); -CALL regression_add_iam_column('enterprise_type', 'VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days'); -CALL regression_add_iam_column('established_date', 'DATE NULL COMMENT ''成立日期'' AFTER enterprise_type'); -CALL regression_add_iam_column('registered_capital', 'DECIMAL(18,2) NULL COMMENT ''注册资本'' AFTER established_date'); -CALL regression_add_iam_column('registered_capital_currency', 'CHAR(3) NULL COMMENT ''注册资本币种 ISO 4217'' AFTER registered_capital'); -CALL regression_add_iam_column('registered_address', 'VARCHAR(500) NULL COMMENT ''注册地址文本'' AFTER registered_capital_currency'); -CALL regression_add_iam_column('internal_remark', 'VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address'); -CALL regression_add_iam_column('approved_at', 'DATETIME(3) NULL COMMENT ''审核通过时间'' AFTER internal_remark'); -CALL regression_add_iam_column('approved_by', 'BIGINT NULL COMMENT ''审核人ID'' AFTER approved_at'); -CALL regression_add_iam_column('reject_reason', 'VARCHAR(500) NULL COMMENT ''驳回原因'' AFTER approved_by'); -DROP PROCEDURE regression_add_iam_column; - --- 注册审核和资质替换依赖的辅助字段;存量测试库也必须具备同一契约。 -DROP PROCEDURE IF EXISTS regression_add_iam_related_column; -DELIMITER $$ -CREATE PROCEDURE regression_add_iam_related_column( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_definition VARCHAR(1000) -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @regression_iam_related_sql = CONCAT( - 'ALTER TABLE `', p_table_name, '` ADD COLUMN `', p_column_name, '` ', p_definition - ); - PREPARE regression_iam_related_stmt FROM @regression_iam_related_sql; - EXECUTE regression_iam_related_stmt; - DEALLOCATE PREPARE regression_iam_related_stmt; - END IF; -END$$ -DELIMITER ; -CALL regression_add_iam_related_column('iam_tenant_qualification', 'review_comment', 'VARCHAR(500) NULL COMMENT ''审核意见'''); -CALL regression_add_iam_related_column('iam_membership', 'created_by', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''创建人ID'''); -CALL regression_add_iam_related_column('iam_membership', 'updated_by', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最后修改人ID'''); -DROP PROCEDURE regression_add_iam_related_column; - --- 先写入父菜单,再写入按钮,避免新增环境出现悬空父节点。 -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 - (5100000, 0, 'PLATFORM', 'MENU', 0, 'platform:customer', '客户管理', '/customers', NULL, 10, 'ACTIVE', 1, 0), - (5101000, 0, 'PLATFORM', 'MENU', 0, 'platform:system', '系统管理', '/system', NULL, 20, 'ACTIVE', 1, 0), - (5102000, 0, 'PLATFORM', 'MENU', 0, 'platform:order', '头程订单', '/first-leg-orders', NULL, 30, 'ACTIVE', 1, 0), - (5103000, 0, 'PLATFORM', 'MENU', 0, 'platform:master-data', '基础资料', '/master-data', NULL, 40, 'ACTIVE', 1, 0), - (5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 1, 0), - (5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 1, 0), - (5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 1, 0), - (5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 1, 0), - (5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', '/system/members', NULL, 40, 'ACTIVE', 1, 0), - (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 1, 0), - (5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 1, 0), - (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 1, 0), - (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 1, 0), - (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 1, 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), - parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), - route_path = VALUES(route_path), - http_method = VALUES(http_method), - sort_no = VALUES(sort_no), - status = 'ACTIVE', - built_in = 1, - deleted = 0; - -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 - (5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', NULL, NULL, 15, 'ACTIVE', 1, 0), - (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', NULL, NULL, 30, 'ACTIVE', 1, 0), - (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 1, 0), - (5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 1, 0), - (5100021, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:approve', '审核通过', NULL, NULL, 60, 'ACTIVE', 1, 0), - (5100022, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reject', '审核驳回', NULL, NULL, 70, 'ACTIVE', 1, 0), - (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增内部用户', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑内部用户', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停内部用户', NULL, NULL, 30, 'ACTIVE', 1, 0), - (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置内部用户密码', NULL, NULL, 40, 'ACTIVE', 1, 0), - (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', NULL, NULL, 50, 'ACTIVE', 1, 0), - (5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', NULL, NULL, 30, 'ACTIVE', 1, 0), - (5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', NULL, NULL, 40, 'ACTIVE', 1, 0), - (5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', NULL, NULL, 50, 'ACTIVE', 1, 0), - (5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', NULL, NULL, 30, 'ACTIVE', 1, 0), - (5101034, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:delete', '删除权限资源', NULL, NULL, 40, 'ACTIVE', 1, 0), - (5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配平台成员角色', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 1, 0), - (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 1, 0), - (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 1, 0), - (5102021, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:task:view', '查看履约任务', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5102022, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:history', '查看分配历史', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5102023, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:assign', '分配履约任务', NULL, NULL, 30, 'ACTIVE', 1, 0), - (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', NULL, NULL, 30, 'ACTIVE', 1, 0), - (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 1, 0), - (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 1, 0), - (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', NULL, NULL, 30, 'ACTIVE', 1, 0), - (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 1, 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), - parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), - sort_no = VALUES(sort_no), - status = 'ACTIVE', - built_in = 1, - deleted = 0; - --- 商户角色维护权限仅在对应父菜单已存在时补齐,不创建演示租户或角色。 -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, - permission_name, sort_no, status, built_in, deleted) -SELECT 5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 1, 0 -WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5200030 AND tenant_id = 10001) -UNION ALL -SELECT 5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 1, 0 -WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5200030 AND tenant_id = 10001) -UNION ALL -SELECT 5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 1, 0 -WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5200030 AND tenant_id = 10001) -UNION ALL -SELECT 5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 1, 0 -WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5300030 AND tenant_id = 0) -UNION ALL -SELECT 5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 1, 0 -WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5300030 AND tenant_id = 0) -UNION ALL -SELECT 5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 1, 0 -WHERE EXISTS (SELECT 1 FROM iam_permission WHERE id = 5300030 AND tenant_id = 0) -ON DUPLICATE KEY UPDATE - permission_name = VALUES(permission_name), - parent_id = VALUES(parent_id), - sort_no = VALUES(sort_no), - status = 'ACTIVE', - built_in = 1, - deleted = 0; - -UPDATE iam_permission - SET built_in = 1 - WHERE id BETWEEN 5000000 AND 5399999 - AND built_in = 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' - WHERE r.tenant_id = 0 - AND r.app_code = 'PLATFORM' - AND r.role_code = 'PLATFORM_ADMIN' - AND r.deleted = 0 - AND p.permission_code IN ( - 'platform:customer', 'platform:customer:list', 'platform:customer:create', - 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export', - 'platform:customer:review', 'platform:customer:approve', 'platform:customer:reject', - 'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit', - 'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role', - 'platform:role:list', 'platform:role:create', 'platform:role:grant', - 'platform:role:edit', 'platform:role:status', 'platform:role:delete', - 'platform:member:list', 'platform:member:grant-role', - 'platform:permission:list', 'platform:permission:create', 'platform:permission:edit', - 'platform:permission:status', 'platform:permission:delete', - 'platform:order', 'platform:order:list', 'platform:order:create', - 'platform:order:edit', 'platform:order:submit', - 'platform:firstleg:task:list', 'platform:firstleg:task:view', - 'platform:firstleg:assignment:history', 'platform:firstleg:assignment:assign', - 'platform:master-data', 'platform:express:list', 'platform:express:create', - 'platform:express:edit', 'platform:express:status', - 'platform:counterparty:list', 'platform:counterparty:create', - 'platform:counterparty:edit', 'platform:counterparty:status', - 'platform:download:list', 'platform:download:all' - ) -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - --- 既有客户列表或审核角色继承详情查看权限,避免升级后丢失原查询能力。 -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id - FROM iam_role_permission existing_grant - JOIN iam_permission existing_permission - ON existing_permission.id = existing_grant.permission_id - JOIN iam_permission view_permission - ON view_permission.tenant_id = 0 - AND view_permission.app_code = 'PLATFORM' - AND view_permission.permission_code = 'platform:customer:view' - WHERE existing_permission.tenant_id = 0 - AND existing_permission.app_code = 'PLATFORM' - AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - --- 已有客户列表或审核角色获得客户导出及本人下载任务查询,不继承查看全部任务权限。 -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id - FROM iam_role_permission existing_grant - JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id - JOIN iam_permission added_permission - ON added_permission.tenant_id = 0 - AND added_permission.app_code = 'PLATFORM' - AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list') - WHERE existing_permission.tenant_id = 0 - AND existing_permission.app_code = 'PLATFORM' - AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') -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, r.tenant_id - FROM iam_role r - JOIN iam_permission p - ON p.tenant_id = r.tenant_id - AND p.app_code = 'MERCHANT' - WHERE r.app_code = 'MERCHANT' - AND r.role_code IN ('MERCHANT_OWNER', 'MERCHANT_ADMIN') - AND r.deleted = 0 - AND p.permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - --- ============================================================================ --- 2. Warehouse:仓库、地址、能力、往来单位、快递公司结构兼容迁移 --- ============================================================================ -USE baiyue_warehouse; - -CREATE TABLE IF NOT EXISTS wh_partner ( - id BIGINT UNSIGNED NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - partner_code VARCHAR(50) NOT NULL, - partner_name VARCHAR(128) NOT NULL, - partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH', - contact_name VARCHAR(64) NOT NULL, - contact_phone VARCHAR(32) NOT NULL, - contact_email VARCHAR(128) NULL, - country_code CHAR(2) NOT NULL, - province VARCHAR(64) NULL, - city VARCHAR(64) NOT NULL, - district VARCHAR(64) NULL, - address_line VARCHAR(255) NOT NULL, - postal_code VARCHAR(20) NULL, - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', - created_by BIGINT UNSIGNED NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_by BIGINT UNSIGNED NULL, - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - deleted TINYINT(1) NOT NULL DEFAULT 0, - version INT NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uk_wh_partner_tenant_code (tenant_id, partner_code, deleted), - KEY idx_wh_partner_tenant_status (tenant_id, status, deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储域内的租户往来单位配置'; - -CREATE TABLE IF NOT EXISTS wh_express_company ( - id BIGINT UNSIGNED NOT NULL COMMENT '快递公司记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所属租户ID', - company_code VARCHAR(50) NOT NULL COMMENT '租户内唯一快递公司编码', - company_name VARCHAR(128) NOT NULL COMMENT '快递公司名称', - service_phone VARCHAR(32) NULL COMMENT '客服电话', - website VARCHAR(255) NULL COMMENT '官方网站', - tracking_url_template VARCHAR(500) NULL COMMENT '轨迹查询地址模板,可包含{trackingNo}', - status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', - remark VARCHAR(500) NULL COMMENT '备注', - created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_by BIGINT UNSIGNED 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_wh_express_tenant_code (tenant_id, company_code, deleted), - KEY idx_wh_express_tenant_status (tenant_id, status, deleted) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户维度快递公司主数据'; - -DROP PROCEDURE IF EXISTS regression_assert_warehouse_table; -DELIMITER $$ -CREATE PROCEDURE regression_assert_warehouse_table(IN p_table_name VARCHAR(64)) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = p_table_name - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'baiyue_warehouse 缺少仓库基础表,请先执行 warehouse.sql'; - END IF; -END$$ -DELIMITER ; -CALL regression_assert_warehouse_table('wh_warehouse'); -CALL regression_assert_warehouse_table('wh_warehouse_address'); -CALL regression_assert_warehouse_table('wh_service_capability'); -DROP PROCEDURE regression_assert_warehouse_table; - -DROP PROCEDURE IF EXISTS regression_add_column; -DELIMITER $$ -CREATE PROCEDURE regression_add_column( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_column_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @regression_column_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), - '` ', p_column_definition - ); - PREPARE regression_column_stmt FROM @regression_column_sql; - EXECUTE regression_column_stmt; - DEALLOCATE PREPARE regression_column_stmt; - END IF; -END$$ -DELIMITER ; - -CALL regression_add_column('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name'); -CALL regression_add_column('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type'); -CALL regression_add_column('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''Asia/Shanghai'' COMMENT ''IANA时区'' AFTER country_code'); -CALL regression_add_column('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone'); -CALL regression_add_column('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name'); -CALL regression_add_column('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone'); -CALL regression_add_column('wh_warehouse', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER contact_email'); -CALL regression_add_column('wh_warehouse', 'remark', 'VARCHAR(500) NULL AFTER status'); -CALL regression_add_column('wh_warehouse', 'created_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_warehouse', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_warehouse', 'updated_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_warehouse', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_warehouse', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); -CALL regression_add_column('wh_warehouse', 'version', 'INT NOT NULL DEFAULT 0'); - -CALL regression_add_column('wh_warehouse_address', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); -CALL regression_add_column('wh_warehouse_address', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); -CALL regression_add_column('wh_warehouse_address', 'address_type', 'VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' AFTER warehouse_id'); -CALL regression_add_column('wh_warehouse_address', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER address_type'); -CALL regression_add_column('wh_warehouse_address', 'state_province', 'VARCHAR(100) NULL'); -CALL regression_add_column('wh_warehouse_address', 'city', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER state_province'); -CALL regression_add_column('wh_warehouse_address', 'district', 'VARCHAR(100) NULL'); -CALL regression_add_column('wh_warehouse_address', 'postal_code', 'VARCHAR(20) NULL'); -CALL regression_add_column('wh_warehouse_address', 'address_line1', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER postal_code'); -CALL regression_add_column('wh_warehouse_address', 'address_line2', 'VARCHAR(255) NULL'); -CALL regression_add_column('wh_warehouse_address', 'latitude', 'DECIMAL(10,7) NULL'); -CALL regression_add_column('wh_warehouse_address', 'longitude', 'DECIMAL(10,7) NULL'); -CALL regression_add_column('wh_warehouse_address', 'created_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_warehouse_address', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_warehouse_address', 'updated_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_warehouse_address', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_warehouse_address', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); -CALL regression_add_column('wh_warehouse_address', 'version', 'INT NOT NULL DEFAULT 0'); - -CALL regression_add_column('wh_service_capability', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); -CALL regression_add_column('wh_service_capability', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); -CALL regression_add_column('wh_service_capability', 'capability_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER warehouse_id'); -CALL regression_add_column('wh_service_capability', 'capability_name', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER capability_code'); -CALL regression_add_column('wh_service_capability', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1'); -CALL regression_add_column('wh_service_capability', 'cutoff_time', 'TIME NULL'); -CALL regression_add_column('wh_service_capability', 'description', 'VARCHAR(500) NULL'); -CALL regression_add_column('wh_service_capability', 'created_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_service_capability', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_service_capability', 'updated_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_service_capability', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_service_capability', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); -CALL regression_add_column('wh_service_capability', 'version', 'INT NOT NULL DEFAULT 0'); - -CALL regression_add_column('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); -CALL regression_add_column('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); -CALL regression_add_column('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); -CALL regression_add_column('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); -CALL regression_add_column('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); -CALL regression_add_column('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); -CALL regression_add_column('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); -CALL regression_add_column('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); -CALL regression_add_column('wh_partner', 'province', 'VARCHAR(64) NULL'); -CALL regression_add_column('wh_partner', 'city', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER province'); -CALL regression_add_column('wh_partner', 'district', 'VARCHAR(64) NULL'); -CALL regression_add_column('wh_partner', 'address_line', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER district'); -CALL regression_add_column('wh_partner', 'postal_code', 'VARCHAR(20) NULL'); -CALL regression_add_column('wh_partner', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER postal_code'); -CALL regression_add_column('wh_partner', 'created_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_partner', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); -CALL regression_add_column('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); - -CALL regression_add_column('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); -CALL regression_add_column('wh_express_company', 'company_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); -CALL regression_add_column('wh_express_company', 'company_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER company_code'); -CALL regression_add_column('wh_express_company', 'service_phone', 'VARCHAR(32) NULL'); -CALL regression_add_column('wh_express_company', 'website', 'VARCHAR(255) NULL'); -CALL regression_add_column('wh_express_company', 'tracking_url_template', 'VARCHAR(500) NULL'); -CALL regression_add_column('wh_express_company', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER tracking_url_template'); -CALL regression_add_column('wh_express_company', 'remark', 'VARCHAR(500) NULL'); -CALL regression_add_column('wh_express_company', 'created_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_express_company', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_express_company', 'updated_by', 'BIGINT UNSIGNED NULL'); -CALL regression_add_column('wh_express_company', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); -CALL regression_add_column('wh_express_company', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); -CALL regression_add_column('wh_express_company', 'version', 'INT NOT NULL DEFAULT 0'); - -DROP PROCEDURE regression_add_column; - -DROP PROCEDURE IF EXISTS regression_add_index; -DELIMITER $$ -CREATE PROCEDURE regression_add_index( - IN p_table_name VARCHAR(64), - IN p_index_name VARCHAR(64), - IN p_index_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND index_name = p_index_name - ) THEN - SET @regression_index_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD ', p_index_definition - ); - PREPARE regression_index_stmt FROM @regression_index_sql; - EXECUTE regression_index_stmt; - DEALLOCATE PREPARE regression_index_stmt; - END IF; -END$$ -DELIMITER ; - -CALL regression_add_index('wh_warehouse', 'idx_wh_tenant_status', 'KEY idx_wh_tenant_status (tenant_id, status, deleted)'); -CALL regression_add_index('wh_warehouse_address', 'idx_address_warehouse', 'KEY idx_address_warehouse (tenant_id, warehouse_id, deleted)'); -CALL regression_add_index('wh_service_capability', 'idx_capability_query', 'KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted)'); -CALL regression_add_index('wh_partner', 'idx_wh_partner_tenant_status', 'KEY idx_wh_partner_tenant_status (tenant_id, status, deleted)'); -CALL regression_add_index('wh_partner', 'uk_wh_partner_tenant_code', 'UNIQUE KEY uk_wh_partner_tenant_code (tenant_id, partner_code, deleted)'); -CALL regression_add_index('wh_express_company', 'idx_wh_express_tenant_status', 'KEY idx_wh_express_tenant_status (tenant_id, status, deleted)'); -CALL regression_add_index('wh_express_company', 'uk_wh_express_tenant_code', 'UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)'); -DROP PROCEDURE regression_add_index; - --- ============================================================================ --- 3. Order:一期订单快照字段、货物字段和持久化幂等记录 --- ============================================================================ -USE baiyue_order; - -DROP PROCEDURE IF EXISTS regression_assert_order_table; -DELIMITER $$ -CREATE PROCEDURE regression_assert_order_table(IN p_table_name VARCHAR(64)) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = p_table_name - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'baiyue_order 缺少订单基础表,请先执行 order.sql'; - END IF; -END$$ -DELIMITER ; -CALL regression_assert_order_table('ord_order'); -CALL regression_assert_order_table('ord_order_cargo'); -DROP PROCEDURE regression_assert_order_table; - -DROP PROCEDURE IF EXISTS regression_add_order_column; -DELIMITER $$ -CREATE PROCEDURE regression_add_order_column( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_definition VARCHAR(1000) -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @regression_order_sql = CONCAT( - 'ALTER TABLE `', p_table_name, '` ADD COLUMN `', p_column_name, '` ', p_definition - ); - PREPARE regression_order_stmt FROM @regression_order_sql; - EXECUTE regression_order_stmt; - DEALLOCATE PREPARE regression_order_stmt; - END IF; -END$$ -DELIMITER ; - -CALL regression_add_order_column('ord_order', 'pickup_method', 'VARCHAR(24) NULL COMMENT ''提货方式'''); -CALL regression_add_order_column('ord_order', 'receiving_warehouse_id', 'BIGINT UNSIGNED NULL COMMENT ''国内收货仓ID'''); -CALL regression_add_order_column('ord_order', 'receiving_warehouse_name', 'VARCHAR(128) NULL COMMENT ''国内收货仓名称快照'''); -CALL regression_add_order_column('ord_order', 'receiving_warehouse_address', 'VARCHAR(512) NULL COMMENT ''国内收货仓完整地址快照'''); -CALL regression_add_order_column('ord_order', 'receiving_time', 'VARCHAR(128) NULL COMMENT ''国内收货仓收货时间快照'''); -CALL regression_add_order_column('ord_order', 'channel_code', 'VARCHAR(64) NULL COMMENT ''运输渠道编码'''); -CALL regression_add_order_column('ord_order', 'channel_name', 'VARCHAR(128) NULL COMMENT ''运输渠道名称快照'''); -CALL regression_add_order_column('ord_order', 'tax_mode', 'VARCHAR(24) NULL COMMENT ''报关买单或退税模式'''); -CALL regression_add_order_column('ord_order', 'container_mode', 'VARCHAR(24) NULL COMMENT ''整柜或散货模式'''); -CALL regression_add_order_column('ord_order', 'customs_inspection', 'TINYINT(1) NULL COMMENT ''是否需要海关查验'''); -CALL regression_add_order_column('ord_order', 'destination_type', 'VARCHAR(24) NULL COMMENT ''FBA、海外仓或私人地址'''); -CALL regression_add_order_column('ord_order', 'amazon_warehouse_code', 'VARCHAR(32) NULL COMMENT ''亚马逊仓库编码'''); -CALL regression_add_order_column('ord_order', 'shipment_id', 'VARCHAR(64) NULL COMMENT ''亚马逊Shipment ID'''); -CALL regression_add_order_column('ord_order', 'reference_id', 'VARCHAR(64) NULL COMMENT ''亚马逊Reference ID'''); -CALL regression_add_order_column('ord_order', 'express_company', 'VARCHAR(64) NULL COMMENT ''履约快递公司名称快照'''); -CALL regression_add_order_column('ord_order', 'express_tracking_no', 'VARCHAR(128) NULL COMMENT ''履约快递单号'''); - -CALL regression_add_order_column('ord_order_cargo', 'sku_no', 'VARCHAR(64) NULL COMMENT ''SKU编号'''); -CALL regression_add_order_column('ord_order_cargo', 'upc_no', 'VARCHAR(64) NULL COMMENT ''UPC编号'''); -CALL regression_add_order_column('ord_order_cargo', 'cargo_name_en', 'VARCHAR(128) NULL COMMENT ''货物英文名称'''); -CALL regression_add_order_column('ord_order_cargo', 'customs_hs_code', 'VARCHAR(20) NULL COMMENT ''目的国海关HS编码'''); -CALL regression_add_order_column('ord_order_cargo', 'units_per_package', 'INT UNSIGNED NULL COMMENT ''每箱件数'''); -CALL regression_add_order_column('ord_order_cargo', 'total_quantity', 'INT UNSIGNED NULL COMMENT ''总件数'''); -CALL regression_add_order_column('ord_order_cargo', 'net_weight_kg', 'DECIMAL(18,3) NULL COMMENT ''净重千克'''); -CALL regression_add_order_column('ord_order_cargo', 'unit_declared_value', 'DECIMAL(18,2) NULL COMMENT ''单件申报价值'''); -CALL regression_add_order_column('ord_order_cargo', 'material', 'VARCHAR(128) NULL COMMENT ''材质'''); -CALL regression_add_order_column('ord_order_cargo', 'product_usage', 'VARCHAR(128) NULL COMMENT ''用途'''); -CALL regression_add_order_column('ord_order_cargo', 'model', 'VARCHAR(64) NULL COMMENT ''型号'''); -CALL regression_add_order_column('ord_order_cargo', 'brand', 'VARCHAR(64) NULL COMMENT ''品牌'''); -DROP PROCEDURE regression_add_order_column; - -CREATE TABLE IF NOT EXISTS ord_idempotency_record ( - id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operation VARCHAR(32) NOT NULL COMMENT '操作类型', - resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源ID', - idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', - request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', - status VARCHAR(16) NOT NULL COMMENT '处理状态', - result_order_id BIGINT UNSIGNED NULL COMMENT '重放结果订单ID', - completed_at DATETIME(3) NULL COMMENT '完成时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), - KEY idx_order_idempotency_created (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单写接口持久化幂等记录'; - --- 兼容曾经只建表但未建立完整索引的测试库。 -SET @order_idempotency_unique_sql = IF( - EXISTS( - SELECT 1 FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND table_name = 'ord_idempotency_record' - AND index_name = 'uk_order_idempotency_scope' - ), - 'SELECT 1', - 'ALTER TABLE ord_idempotency_record ADD UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key)' -); -PREPARE order_idempotency_unique_stmt FROM @order_idempotency_unique_sql; -EXECUTE order_idempotency_unique_stmt; -DEALLOCATE PREPARE order_idempotency_unique_stmt; - -SET @order_idempotency_created_sql = IF( - EXISTS( - SELECT 1 FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND table_name = 'ord_idempotency_record' - AND index_name = 'idx_order_idempotency_created' - ), - 'SELECT 1', - 'ALTER TABLE ord_idempotency_record ADD KEY idx_order_idempotency_created (created_at)' -); -PREPARE order_idempotency_created_stmt FROM @order_idempotency_created_sql; -EXECUTE order_idempotency_created_stmt; -DEALLOCATE PREPARE order_idempotency_created_stmt; - --- ============================================================================ --- 4. Firstleg:履约任务分页索引 --- ============================================================================ -USE baiyue_firstleg; - -DROP PROCEDURE IF EXISTS regression_assert_firstleg_table; -DELIMITER $$ -CREATE PROCEDURE regression_assert_firstleg_table() -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = 'fl_fulfillment_task' - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'baiyue_firstleg 缺少履约任务基础表,请先执行 firstleg.sql'; - END IF; -END$$ -DELIMITER ; -CALL regression_assert_firstleg_table(); -DROP PROCEDURE regression_assert_firstleg_table; - -SET @firstleg_task_list_index_sql = IF( - EXISTS( - SELECT 1 FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND table_name = 'fl_fulfillment_task' - AND index_name = 'idx_task_tenant_created' - ), - 'SELECT 1', - 'CREATE INDEX idx_task_tenant_created ON fl_fulfillment_task (tenant_id, created_at)' -); -PREPARE firstleg_task_list_index_stmt FROM @firstleg_task_list_index_sql; -EXECUTE firstleg_task_list_index_stmt; -DEALLOCATE PREPARE firstleg_task_list_index_stmt; - --- ============================================================================ --- 5. File:公共导出与下载中心任务表 --- ============================================================================ -USE baiyue_file; - -CREATE TABLE IF NOT EXISTS file_export_task ( - id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0', - app_code VARCHAR(24) NOT NULL COMMENT '申请端', - task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号', - export_type VARCHAR(64) NOT NULL COMMENT '导出类型', - format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式', - task_name VARCHAR(128) NOT NULL COMMENT '任务名称', - parameters_json JSON NOT NULL COMMENT '筛选条件快照', - status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', - total_rows BIGINT UNSIGNED NULL, - processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0, - file_name VARCHAR(255) NULL, - object_key VARCHAR(512) NULL, - content_type VARCHAR(128) NULL, - size_bytes BIGINT UNSIGNED NULL, - failure_message VARCHAR(1000) NULL, - requested_by BIGINT UNSIGNED NOT NULL, - requested_username VARCHAR(128) NULL, - requested_at DATETIME(3) NOT NULL, - started_at DATETIME(3) NULL, - completed_at DATETIME(3) NULL, - expires_at DATETIME(3) NULL, - created_by BIGINT UNSIGNED NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_by BIGINT UNSIGNED NULL, - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - deleted TINYINT(1) NOT NULL DEFAULT 0, - version INT NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uk_file_export_task_no (task_no), - KEY idx_file_export_owner (tenant_id, app_code, requested_by, requested_at), - KEY idx_file_export_status (status, requested_at), - KEY idx_file_export_expiry (status, expires_at) -) ENGINE=InnoDB COMMENT='公共异步导出任务表'; - --- ============================================================================ --- 6. 执行后只读核验 --- ============================================================================ -SELECT - 'IAM权限资源' AS check_item, - COUNT(*) AS actual_count, - 47 AS expected_count, - IF(COUNT(*) = 47, '通过', '请检查缺失权限') AS check_result -FROM baiyue_iam.iam_permission -WHERE deleted = 0 - AND permission_code IN ( - 'platform:customer', 'platform:customer:list', 'platform:customer:create', - 'platform:customer:view', 'platform:customer:edit', 'platform:customer:status', 'platform:customer:export', - 'platform:customer:review', 'platform:customer:approve', 'platform:customer:reject', - 'platform:system', 'platform:user:list', 'platform:user:create', 'platform:user:edit', - 'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role', - 'platform:role:list', 'platform:role:create', 'platform:role:grant', - 'platform:role:edit', 'platform:role:status', 'platform:role:delete', - 'platform:member:list', 'platform:member:grant-role', - 'platform:permission:list', 'platform:permission:create', 'platform:permission:edit', - 'platform:permission:status', 'platform:permission:delete', - 'platform:order', 'platform:order:list', 'platform:order:create', - 'platform:order:edit', 'platform:order:submit', - 'platform:firstleg:task:list', 'platform:firstleg:task:view', - 'platform:firstleg:assignment:history', 'platform:firstleg:assignment:assign', - 'platform:master-data', 'platform:express:list', 'platform:express:create', - 'platform:express:edit', 'platform:express:status', - 'platform:counterparty:list', 'platform:counterparty:create', - 'platform:counterparty:edit', 'platform:counterparty:status', - 'platform:download:list', 'platform:download:all' - ); - -SELECT - 'IAM客户档案字段' AS check_item, - COUNT(*) AS actual_count, - 10 AS expected_count, - IF(COUNT(*) = 10, '通过', '请检查客户档案字段迁移') AS check_result -FROM information_schema.columns -WHERE table_schema = 'baiyue_iam' - AND table_name = 'iam_tenant' - AND column_name IN ( - 'legal_representative', 'contact_wechat', 'sales_owner_name', 'payment_term_days', 'enterprise_type', - 'established_date', 'registered_capital', 'registered_capital_currency', - 'registered_address', 'internal_remark' - ); - -SELECT - 'IAM注册审核字段' AS check_item, - COUNT(*) AS actual_count, - 4 AS expected_count, - IF(COUNT(*) = 4, '通过', '请检查租户审核状态和结果字段') AS check_result -FROM information_schema.columns -WHERE table_schema = 'baiyue_iam' - AND table_name = 'iam_tenant' - AND column_name IN ('status', 'approved_at', 'approved_by', 'reject_reason'); - -SELECT - 'IAM资质与成员审计字段' AS check_item, - ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_iam' - AND table_name = 'iam_tenant_qualification' - AND column_name = 'review_comment' - ) + ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_iam' - AND table_name = 'iam_membership' - AND column_name IN ('created_by', 'updated_by') - ) AS actual_count, - 3 AS expected_count, - IF( - ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_iam' - AND table_name = 'iam_tenant_qualification' - AND column_name = 'review_comment' - ) + ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = 'baiyue_iam' - AND table_name = 'iam_membership' - AND column_name IN ('created_by', 'updated_by') - ) = 3, - '通过', '请检查资质审核备注和成员审计字段' - ) AS check_result; - -SELECT - 'IAM全局企业管理员模板' AS check_item, - COUNT(DISTINCT owner_role.id) AS role_count, - COUNT(DISTINCT role_permission.permission_id) AS actual_permission_count, - (SELECT COUNT(*) FROM baiyue_iam.iam_permission permission - WHERE permission.tenant_id = 0 AND permission.app_code = 'MERCHANT' - AND permission.permission_type IN ('MENU', 'BUTTON') - AND permission.status = 'ACTIVE' AND permission.deleted = 0) AS expected_permission_count, - IF( - COUNT(DISTINCT owner_role.id) = 1 - AND COUNT(DISTINCT role_permission.permission_id) = ( - SELECT COUNT(*) FROM baiyue_iam.iam_permission permission - WHERE permission.tenant_id = 0 AND permission.app_code = 'MERCHANT' - AND permission.permission_type IN ('MENU', 'BUTTON') - AND permission.status = 'ACTIVE' AND permission.deleted = 0 - ) - AND COUNT(DISTINCT role_permission.permission_id) > 0, - '通过', '请检查全局MERCHANT_OWNER角色及授权' - ) AS check_result -FROM baiyue_iam.iam_role owner_role -LEFT JOIN baiyue_iam.iam_role_permission role_permission - ON role_permission.role_id = owner_role.id AND role_permission.tenant_id = 0 -WHERE owner_role.tenant_id = 0 AND owner_role.app_code = 'MERCHANT' - AND owner_role.role_code = 'MERCHANT_OWNER' - AND owner_role.role_type = 'SYSTEM' AND owner_role.status = 'ACTIVE' AND owner_role.deleted = 0; - -SELECT - table_name, - COUNT(*) AS column_count -FROM information_schema.columns -WHERE table_schema = 'baiyue_warehouse' - AND table_name IN ( - 'wh_warehouse', 'wh_warehouse_address', 'wh_service_capability', - 'wh_partner', 'wh_express_company' - ) -GROUP BY table_name -ORDER BY table_name; - -SELECT - IF(COUNT(*) = 1, '通过', '缺失') AS idempotency_table, - 'baiyue_order.ord_idempotency_record' AS object_name -FROM information_schema.tables -WHERE table_schema = 'baiyue_order' - AND table_name = 'ord_idempotency_record'; - -SELECT - IF(COUNT(*) > 0, '通过', '缺失') AS idempotency_unique_index, - 'uk_order_idempotency_scope' AS object_name -FROM information_schema.statistics -WHERE table_schema = 'baiyue_order' - AND table_name = 'ord_idempotency_record' - AND index_name = 'uk_order_idempotency_scope'; - -SELECT - IF(COUNT(*) > 0, '通过', '缺失') AS firstleg_task_index, - 'idx_task_tenant_created' AS object_name -FROM information_schema.statistics -WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_fulfillment_task' - AND index_name = 'idx_task_tenant_created'; - -SELECT - IF(COUNT(*) = 1, '通过', '缺失') AS export_task_table, - 'baiyue_file.file_export_task' AS object_name -FROM information_schema.tables -WHERE table_schema = 'baiyue_file' - AND table_name = 'file_export_task'; - -SELECT 'MIGRATION_COMPLETED' AS migration_status, - '请确认上方所有核验结果均为通过后再启动服务回归' AS message; diff --git a/deploy/sql/2026-08-03-一期头程下单前置增量迁移.sql b/deploy/sql/2026-08-03-一期头程下单前置增量迁移.sql deleted file mode 100644 index 28c4efd71..000000000 --- a/deploy/sql/2026-08-03-一期头程下单前置增量迁移.sql +++ /dev/null @@ -1,202 +0,0 @@ --- 一期头程下单前置增量迁移。 --- 适用:旧版测试库已存在基础表,但缺少客户档案、往来单位或订单一期字段。 --- 特性:仅新增缺失字段/表,可重复执行;不删除数据,不写入订单业务数据。 -SET NAMES utf8mb4; - --- 1. IAM:下单资格校验会查询以下租户档案字段。 -USE baiyue_iam; -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'service_package'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN service_package VARCHAR(64) NOT NULL DEFAULT ''基础版'' COMMENT ''客户服务套餐'' AFTER contact_email'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'contact_wechat'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN contact_wechat VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'sales_owner_name'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN sales_owner_name VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'payment_term_days'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN payment_term_days SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'enterprise_type'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN enterprise_type VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'established_date'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN established_date DATE NULL COMMENT ''成立日期'' AFTER enterprise_type'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital DECIMAL(18,2) NULL COMMENT ''注册资本'' AFTER established_date'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital_currency'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital_currency CHAR(3) NULL COMMENT ''注册资本币种'' AFTER registered_capital'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_address'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_address VARCHAR(500) NULL COMMENT ''注册地址'' AFTER registered_capital_currency'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'internal_remark'), - 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN internal_remark VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address'); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- 2. Warehouse:订单主数据校验依赖往来单位及其 SHIPPER 类型。 -USE baiyue_warehouse; -DROP PROCEDURE IF EXISTS order_prerequisite_add_partner_column; -DELIMITER $$ -CREATE PROCEDURE order_prerequisite_add_partner_column( - IN p_column VARCHAR(64), - IN p_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = 'wh_partner' AND column_name = p_column - ) THEN - SET @sql = CONCAT('ALTER TABLE wh_partner ADD COLUMN `', p_column, '` ', p_definition); - PREPARE add_column_stmt FROM @sql; - EXECUTE add_column_stmt; - DEALLOCATE PREPARE add_column_stmt; - END IF; -END$$ -DELIMITER ; - -CALL order_prerequisite_add_partner_column('tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0'); -CALL order_prerequisite_add_partner_column('data_scope_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0'); -CALL order_prerequisite_add_partner_column('partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''''); -CALL order_prerequisite_add_partner_column('partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''''); -CALL order_prerequisite_add_partner_column('partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'''); -CALL order_prerequisite_add_partner_column('contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''''); -CALL order_prerequisite_add_partner_column('contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''''); -CALL order_prerequisite_add_partner_column('contact_email', 'VARCHAR(128) NULL'); -CALL order_prerequisite_add_partner_column('country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'''); -CALL order_prerequisite_add_partner_column('province', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_partner_column('city', 'VARCHAR(64) NOT NULL DEFAULT '''''); -CALL order_prerequisite_add_partner_column('district', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_partner_column('address_line', 'VARCHAR(255) NOT NULL DEFAULT '''''); -CALL order_prerequisite_add_partner_column('postal_code', 'VARCHAR(20) NULL'); -CALL order_prerequisite_add_partner_column('status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'''); -CALL order_prerequisite_add_partner_column('remark', 'VARCHAR(500) NULL'); -CALL order_prerequisite_add_partner_column('created_by', 'BIGINT UNSIGNED NULL'); -CALL order_prerequisite_add_partner_column('created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); -CALL order_prerequisite_add_partner_column('updated_by', 'BIGINT UNSIGNED NULL'); -CALL order_prerequisite_add_partner_column('updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); -CALL order_prerequisite_add_partner_column('deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); -CALL order_prerequisite_add_partner_column('version', 'INT NOT NULL DEFAULT 0'); -DROP PROCEDURE IF EXISTS order_prerequisite_add_partner_column; - -UPDATE wh_partner SET data_scope_id = tenant_id WHERE data_scope_id = 0; -CREATE TABLE IF NOT EXISTS wh_partner_type ( - partner_id BIGINT UNSIGNED NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - data_scope_id BIGINT UNSIGNED NOT NULL, - partner_type VARCHAR(32) NOT NULL, - PRIMARY KEY (partner_id, partner_type), - KEY idx_wh_partner_type_scope (tenant_id, data_scope_id, partner_type, partner_id) -) ENGINE=InnoDB COMMENT='往来单位业务类型'; - --- 将既有有效往来单位的兼容单值类型回填到类型关联表。 -INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type) -SELECT id, tenant_id, data_scope_id, partner_type -FROM wh_partner -WHERE deleted = 0 AND partner_type <> 'BOTH'; -INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type) -SELECT id, tenant_id, data_scope_id, 'CUSTOMER' -FROM wh_partner -WHERE deleted = 0 AND partner_type = 'BOTH'; -INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type) -SELECT id, tenant_id, data_scope_id, 'SUPPLIER' -FROM wh_partner -WHERE deleted = 0 AND partner_type = 'BOTH'; - --- 一期演示商户的既有客户同时作为发货单位,供头程订单 customerId=191000000000000001 使用。 -INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type) -SELECT id, tenant_id, data_scope_id, 'SHIPPER' -FROM wh_partner -WHERE id = 191000000000000001 AND tenant_id = 10001 AND status = 'ACTIVE' AND deleted = 0; - --- 3. Order:草稿创建、货物快照和 Idempotency-Key 持久化。 -USE baiyue_order; -DROP PROCEDURE IF EXISTS order_prerequisite_add_order_column; -DELIMITER $$ -CREATE PROCEDURE order_prerequisite_add_order_column( - IN p_table VARCHAR(64), - IN p_column VARCHAR(64), - IN p_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = p_table AND column_name = p_column - ) THEN - SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); - PREPARE add_column_stmt FROM @sql; - EXECUTE add_column_stmt; - DEALLOCATE PREPARE add_column_stmt; - END IF; -END$$ -DELIMITER ; - -CALL order_prerequisite_add_order_column('ord_order', 'pickup_method', 'VARCHAR(24) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'receiving_warehouse_id', 'BIGINT UNSIGNED NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'receiving_warehouse_name', 'VARCHAR(128) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'receiving_warehouse_address', 'VARCHAR(600) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'receiving_time', 'VARCHAR(128) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'channel_code', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'channel_name', 'VARCHAR(128) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'tax_mode', 'VARCHAR(24) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'container_mode', 'VARCHAR(24) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'customs_inspection', 'TINYINT(1) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'destination_type', 'VARCHAR(24) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'amazon_warehouse_code', 'VARCHAR(32) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'shipment_id', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'reference_id', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'express_company', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_order_column('ord_order', 'express_tracking_no', 'VARCHAR(128) NULL'); - -CALL order_prerequisite_add_order_column('ord_order_cargo', 'sku_no', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'upc_no', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'cargo_name_en', 'VARCHAR(128) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'customs_hs_code', 'VARCHAR(20) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'units_per_package', 'INT UNSIGNED NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'total_quantity', 'INT UNSIGNED NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'net_weight_kg', 'DECIMAL(18,3) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'unit_declared_value', 'DECIMAL(18,2) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'material', 'VARCHAR(128) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'product_usage', 'VARCHAR(128) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'model', 'VARCHAR(64) NULL'); -CALL order_prerequisite_add_order_column('ord_order_cargo', 'brand', 'VARCHAR(64) NULL'); -DROP PROCEDURE IF EXISTS order_prerequisite_add_order_column; - -CREATE TABLE IF NOT EXISTS ord_idempotency_record ( - id BIGINT UNSIGNED NOT NULL, - tenant_id BIGINT UNSIGNED NOT NULL, - operator_id BIGINT UNSIGNED NOT NULL, - operation VARCHAR(32) NOT NULL, - resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - idempotency_key VARCHAR(128) NOT NULL, - request_hash CHAR(64) NOT NULL, - status VARCHAR(16) NOT NULL, - result_order_id BIGINT UNSIGNED NULL, - completed_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (id), - UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), - KEY idx_order_idempotency_created (created_at) -) ENGINE=InnoDB COMMENT='订单幂等记录'; diff --git a/deploy/sql/2026-08-03-二期入库表结构-TEST环境一键执行.sql b/deploy/sql/2026-08-03-二期入库表结构-TEST环境一键执行.sql deleted file mode 100644 index 8ed4b5c61..000000000 --- a/deploy/sql/2026-08-03-二期入库表结构-TEST环境一键执行.sql +++ /dev/null @@ -1,699 +0,0 @@ --- 柏悦头程二期入库表结构:TEST环境一键执行脚本 --- 生成日期:2026-08-03 --- 适用版本:MySQL 8.0及以上 --- 执行范围:只新增二期表、字段、索引和约束,不插入、更新或删除业务数据 --- 目标数据库:baiyue_firstleg、baiyue_warehouse --- 前置条件:一期基线表 fl_fulfillment_task、wh_warehouse 已存在 --- 注意:MySQL DDL会自动提交,请使用具有目标库建表权限的测试环境账号执行 - -SET NAMES utf8mb4; -SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; - -SELECT VERSION() AS mysql_version, - '开始执行二期入库表结构' AS execution_message; - - -SET NAMES utf8mb4; - -CREATE DATABASE IF NOT EXISTS baiyue_firstleg - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_firstleg; - -CREATE TABLE IF NOT EXISTS fl_inbound_order ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', - order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', - order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', - inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码', - process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态', - order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', - measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态', - fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态', - fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', - notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态', - expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', - customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID', - company_name VARCHAR(128) NULL COMMENT '运营公司名称快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID', - warehouse_name VARCHAR(128) NOT NULL COMMENT '实际作业仓库名称快照', - delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', - delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型', - channel_code VARCHAR(64) NULL COMMENT '渠道编码', - channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', - tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式', - container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式', - customs_inspection TINYINT(1) NULL COMMENT '是否商检', - destination_type VARCHAR(24) NULL COMMENT '目的仓类型', - destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID', - destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码', - destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', - hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', - current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', - total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', - actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', - chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', - actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', - chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', - pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照', - volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg', - actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg', - measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间', - inbounded_at DATETIME(3) NULL COMMENT '入库完成时间', - express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照', - express_company_name VARCHAR(128) NULL COMMENT '快递公司名称快照', - express_tracking_no VARCHAR(128) NULL COMMENT '快递单号快照', - public_remark VARCHAR(1000) NULL COMMENT '客户可见备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', - 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_inbound_tenant_code (tenant_id, inbound_code), - UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), - KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), - KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), - KEY idx_inbound_fee_time (tenant_id, fee_review_status, created_at, id), - KEY idx_inbound_warehouse_status (tenant_id, warehouse_id, process_status, created_at), - KEY idx_inbound_customer_time (tenant_id, customer_id, created_at), - KEY idx_inbound_company_time (tenant_id, company_id, created_at), - KEY idx_inbound_arrival (tenant_id, expected_arrival_at), - KEY idx_inbound_destination (tenant_id, destination_code, created_at), - KEY idx_inbound_channel (tenant_id, channel_code, created_at), - CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) -) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - pickup_method VARCHAR(24) NULL COMMENT '提货方式', - pickup_at DATETIME(3) NULL COMMENT '提货时间', - contact_name VARCHAR(64) NULL COMMENT '提货联系人', - contact_phone VARCHAR(32) NULL COMMENT '提货联系人电话', - contact_email VARCHAR(128) NULL COMMENT '提货联系人邮箱', - country_code CHAR(2) NULL COMMENT '国家二字码', - province VARCHAR(64) NULL COMMENT '省或州', - city VARCHAR(64) NULL COMMENT '城市', - district VARCHAR(64) NULL COMMENT '区县', - address_line VARCHAR(512) NULL COMMENT '提货详细地址', - postal_code VARCHAR(20) NULL COMMENT '提货邮编', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_pickup_inbound (tenant_id, inbound_id), - CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单提货信息不可变快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - destination_type VARCHAR(24) NULL COMMENT '目的仓类型', - destination_code VARCHAR(32) NULL COMMENT '目的仓编码', - destination_name VARCHAR(128) NULL COMMENT '目的仓名称', - country_code CHAR(2) NULL COMMENT '国家二字码', - state_province VARCHAR(100) NULL COMMENT '省或州', - city VARCHAR(100) NULL COMMENT '城市', - address_line VARCHAR(512) NULL COMMENT '详细地址', - postal_code VARCHAR(20) NULL COMMENT '邮编', - contact_name VARCHAR(64) NULL COMMENT '联系人', - contact_phone VARCHAR(32) NULL COMMENT '联系人电话', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_destination_inbound (tenant_id, inbound_id), - CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单目的仓地址和联系人快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - loading_status VARCHAR(24) NULL COMMENT '配载状态快照', - container_no VARCHAR(64) NULL COMMENT '集装箱号', - cabinet_no VARCHAR(64) NULL COMMENT '柜号或内部柜单号', - vessel_name VARCHAR(128) NULL COMMENT '船名', - voyage_no VARCHAR(64) NULL COMMENT '航次', - sailing_at DATETIME(3) NULL COMMENT '开船时间', - port_arrival_at DATETIME(3) NULL COMMENT '到港时间', - source_updated_at DATETIME(3) NULL COMMENT '上游数据更新时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_transport_inbound (tenant_id, inbound_id), - KEY idx_transport_container (tenant_id, container_no), - KEY idx_transport_cabinet (tenant_id, cabinet_no), - KEY idx_transport_loading (tenant_id, loading_status, sailing_at), - CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_measurement ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL', - source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码', - external_event_no VARCHAR(128) NULL COMMENT '第三方事件号', - device_code VARCHAR(64) NULL COMMENT '测量设备编码', - warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码', - measured_at DATETIME(3) NULL COMMENT '设备测量时间', - status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态', - raw_payload_json JSON NULL COMMENT '第三方原始报文', - total_package_count INT UNSIGNED NULL COMMENT '本批次总箱数', - total_volume_raw_cbm DECIMAL(18,6) NULL COMMENT '本批次原始总体积m3', - total_volume_cbm DECIMAL(18,4) NULL COMMENT '本批次业务总体积m3', - created_by BIGINT UNSIGNED NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no), - KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at), - CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据'; - -CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', - item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号', - package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数', - length_cm DECIMAL(12,3) NOT NULL COMMENT '原始长度cm', - width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm', - height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm', - volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3', - volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3', - calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', - source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', - remark VARCHAR(500) NULL COMMENT '测量明细备注', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no), - CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) -) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细'; - -CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( - id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', - ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', - ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', - ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', - ocr_weight_unit VARCHAR(8) NULL COMMENT 'OCR识别原始单位', - confidence DECIMAL(6,5) NULL COMMENT '识别置信度', - adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', - adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', - recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态', - adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', - adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', - adjusted_at DATETIME(3) NULL COMMENT '修正时间', - confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - 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_weight_inbound_file (tenant_id, inbound_id, file_id), - KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), - CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; - -CREATE TABLE IF NOT EXISTS fl_measure_review_record ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID', - review_result VARCHAR(24) NOT NULL COMMENT '审核结果', - review_reason VARCHAR(1000) NULL COMMENT '审核或驳回原因', - exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常放行', - release_reason VARCHAR(1000) NULL COMMENT '异常放行原因', - allow_rejected_config TINYINT(1) NOT NULL DEFAULT 0 COMMENT '操作时Nacos异常放行开关值', - reviewer_id BIGINT UNSIGNED NOT NULL COMMENT '审核人ID', - reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照', - reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间', - PRIMARY KEY (id), - KEY idx_review_inbound_time (tenant_id, inbound_id, reviewed_at), - CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) -) ENGINE=InnoDB COMMENT='测量审核、驳回和异常放行不可变记录'; - -CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( - id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码', - rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型', - charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', - tax_mode VARCHAR(24) NULL COMMENT '包税或不包税类型', - conversion_factor DECIMAL(18,6) NOT NULL COMMENT '重量体积换算系数', - rounding_scale TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '结果保留小数位数', - rounding_mode VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式', - currency CHAR(3) NOT NULL COMMENT '默认币种', - effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', - effective_to DATETIME(3) NULL COMMENT '生效结束时间', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态', - remark VARCHAR(500) 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 '逻辑删除标记', - PRIMARY KEY (id), - UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version), - KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to) -) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; - -CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( - id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', - rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', - origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', - origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', - channel_code VARCHAR(64) NULL COMMENT '渠道编码', - carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID', - carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', - destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码', - charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM', - unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价', - currency CHAR(3) NOT NULL COMMENT '币种', - additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费', - effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', - effective_to DATETIME(3) NULL COMMENT '生效结束时间', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态', - remark VARCHAR(500) 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 '逻辑删除标记', - PRIMARY KEY (id), - UNIQUE KEY uk_fba_rate_version (tenant_id, rate_code, rate_version), - KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, channel_code, destination_code, charge_unit, status, effective_from) -) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'; - -CREATE TABLE IF NOT EXISTS fl_inbound_fee ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号', - fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', - charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', - charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照', - carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', - charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', - conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照', - actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', - actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', - converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', - chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', - charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', - currency CHAR(3) NOT NULL COMMENT '币种', - subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额', - additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额', - total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额', - fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态', - review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', - exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成', - calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID', - calculated_at DATETIME(3) NOT NULL COMMENT '试算时间', - confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', - sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID', - sent_at DATETIME(3) NULL COMMENT '发送客户时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_fee_no (tenant_id, fee_no), - UNIQUE KEY uk_inbound_fee_version (tenant_id, inbound_id, fee_version), - KEY idx_inbound_fee_status (tenant_id, inbound_id, fee_status, review_status), - CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id), - CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) -) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( - id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID', - item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号', - fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码', - fee_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', - quantity DECIMAL(18,4) NOT NULL COMMENT '计费数量', - unit VARCHAR(16) NOT NULL COMMENT '计费单位', - unit_price DECIMAL(18,6) NOT NULL COMMENT '单价', - amount DECIMAL(18,2) NOT NULL COMMENT '金额', - currency CHAR(3) NOT NULL COMMENT '币种', - automatic_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动计算', - remark VARCHAR(500) NULL COMMENT '费用备注', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_fee_item_no (tenant_id, fee_id, item_no), - CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id) -) ENGINE=InnoDB COMMENT='入库头程费用项目明细'; - -CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( - id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', - allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积', - allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', - location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号', - allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), - UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), - KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), - CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; - -CREATE TABLE IF NOT EXISTS fl_inbound_file ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID', - file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT', - file_name_snapshot VARCHAR(255) NOT NULL COMMENT '文件名快照', - visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围', - file_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '业务文件版本', - uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传人ID', - uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', - deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_file_relation (tenant_id, inbound_id, file_id, file_type), - KEY idx_inbound_file_type (tenant_id, inbound_id, file_type, deleted), - CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单与文件中心对象的分类关系'; - -CREATE TABLE IF NOT EXISTS fl_inbound_hold_record ( - id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE', - reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因', - evidence_file_id BIGINT UNSIGNED NULL COMMENT '证据文件ID', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY (id), - KEY idx_hold_inbound_time (tenant_id, inbound_id, operated_at), - CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单扣货和解除扣货不可变历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_internal_note ( - id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - KEY idx_internal_note_time (tenant_id, inbound_id, created_at), - CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单客服内部备注历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_status_history ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - from_status VARCHAR(24) NULL COMMENT '变更前状态', - to_status VARCHAR(24) NOT NULL COMMENT '变更后状态', - action VARCHAR(32) NOT NULL COMMENT '触发动作', - reason VARCHAR(1000) NULL COMMENT '变更原因', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', - PRIMARY KEY (id), - KEY idx_inbound_history_time (tenant_id, inbound_id, occurred_at), - CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单流程状态不可变历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_outbox ( - id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID', - aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', - aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - payload_json JSON NOT NULL COMMENT '事件负载快照', - publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', - retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', - next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', - published_at DATETIME(3) NULL COMMENT '发布时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - KEY idx_inbound_outbox_publish (publish_status, next_retry_at, created_at) -) ENGINE=InnoDB COMMENT='入库事务Outbox事件表'; - -CREATE TABLE IF NOT EXISTS fl_inbound_idempotency ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', - operation VARCHAR(40) NOT NULL COMMENT '操作类型', - operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID', - inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0', - idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键', - request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', - status VARCHAR(16) NOT NULL COMMENT '处理状态', - result_json JSON NULL COMMENT '成功结果摘要', - completed_at DATETIME(3) NULL COMMENT '完成时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key), - KEY idx_inbound_idempotency_created (created_at) -) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录'; - --- 二期托盘、库位、预占与占用表结构。 --- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。 --- 可在 warehouse.sql 和一期增量脚本之后执行;所有建表均可重复执行。 -SET NAMES utf8mb4; - -CREATE DATABASE IF NOT EXISTS baiyue_warehouse - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_warehouse; - -CREATE TABLE IF NOT EXISTS wh_pallet ( - id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码', - pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', - pallet_name VARCHAR(100) NULL COMMENT '托盘名称', - capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED', - remark VARCHAR(500) 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_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), - CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; - -CREATE TABLE IF NOT EXISTS wh_location ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - zone_code VARCHAR(50) NULL COMMENT '库区编码', - zone_name VARCHAR(100) NULL COMMENT '库区名称', - location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码', - location_name VARCHAR(100) NULL COMMENT '库位名称', - location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET', - max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED', - remark VARCHAR(500) 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_location_tenant_warehouse_code (tenant_id, warehouse_id, location_code), - KEY idx_location_available (tenant_id, warehouse_id, status, deleted), - KEY idx_location_zone (tenant_id, warehouse_id, zone_code, status, deleted), - CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT ck_location_max_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) -) ENGINE=InnoDB COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验'; - -CREATE TABLE IF NOT EXISTS wh_location_reservation ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号', - idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', - source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', - business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', - inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - reservation_status VARCHAR(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED', - expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间', - confirmed_at DATETIME(3) NULL COMMENT '确认占用时间', - released_at DATETIME(3) NULL COMMENT '释放时间', - release_reason VARCHAR(500) NULL COMMENT '释放或过期原因', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '活动预占唯一标记', - PRIMARY KEY (id), - UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), - UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), - UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), - KEY idx_location_reservation_expire (reservation_status, expires_at), - KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), - CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), - CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; - -CREATE TABLE IF NOT EXISTS wh_location_occupancy ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号', - reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID', - source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', - business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', - inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - allocated_volume_cbm DECIMAL(18,6) NOT NULL COMMENT '本托盘分配体积CBM', - allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', - occupancy_status VARCHAR(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED', - occupied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间', - released_at DATETIME(3) NULL COMMENT '释放时间', - release_reason VARCHAR(500) NULL COMMENT '释放原因', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - active_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '活动占用唯一标记', - PRIMARY KEY (id), - UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), - UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), - UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_occupancy), - KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), - KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), - CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), - CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), - CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), - CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; - --- 头程入库单属于其他数据库,不建立跨库外键。 - - --- 执行后结构核对:只输出检查结果,不写入业务数据。 -SELECT '头程二期表数量' AS check_item, - COUNT(*) AS actual_value, - 19 AS expected_value, - IF(COUNT(*) = 19, 'PASS', 'FAIL') AS check_result -FROM information_schema.TABLES -WHERE TABLE_SCHEMA = 'baiyue_firstleg' - AND TABLE_NAME IN ( - 'fl_inbound_order', - 'fl_inbound_pickup_snapshot', - 'fl_inbound_destination_snapshot', - 'fl_inbound_transport_snapshot', - 'fl_inbound_measurement', - 'fl_inbound_measurement_item', - 'fl_inbound_weight_result', - 'fl_measure_review_record', - 'fl_customer_charge_rule', - 'fl_carrier_fba_rate', - 'fl_inbound_fee', - 'fl_inbound_fee_item', - 'fl_inbound_pallet_allocation', - 'fl_inbound_file', - 'fl_inbound_hold_record', - 'fl_inbound_internal_note', - 'fl_inbound_status_history', - 'fl_inbound_outbox', - 'fl_inbound_idempotency' - ) -UNION ALL -SELECT '仓库二期表数量' AS check_item, - COUNT(*) AS actual_value, - 4 AS expected_value, - IF(COUNT(*) = 4, 'PASS', 'FAIL') AS check_result -FROM information_schema.TABLES -WHERE TABLE_SCHEMA = 'baiyue_warehouse' - AND TABLE_NAME IN ( - 'wh_pallet', - 'wh_location', - 'wh_location_reservation', - 'wh_location_occupancy' - ); - -SELECT '关键唯一索引数量' AS check_item, - COUNT(DISTINCT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME, '.', INDEX_NAME)) AS actual_value, - 5 AS expected_value, - IF(COUNT(DISTINCT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME, '.', INDEX_NAME)) = 5, 'PASS', 'FAIL') AS check_result -FROM information_schema.STATISTICS -WHERE (TABLE_SCHEMA = 'baiyue_firstleg' - AND TABLE_NAME = 'fl_inbound_order' - AND INDEX_NAME IN ('uk_inbound_tenant_code', 'uk_inbound_tenant_order')) - OR (TABLE_SCHEMA = 'baiyue_warehouse' - AND TABLE_NAME = 'wh_location_reservation' - AND INDEX_NAME = 'uk_location_active_reservation') - OR (TABLE_SCHEMA = 'baiyue_warehouse' - AND TABLE_NAME = 'wh_location_occupancy' - AND INDEX_NAME IN ('uk_location_active_occupancy', 'uk_pallet_active_occupancy')); - -SELECT '关键字段数量' AS check_item, - COUNT(*) AS actual_value, - 8 AS expected_value, - IF(COUNT(*) = 8, 'PASS', 'FAIL') AS check_result -FROM information_schema.COLUMNS -WHERE (TABLE_SCHEMA = 'baiyue_firstleg' - AND TABLE_NAME = 'fl_inbound_order' - AND COLUMN_NAME IN ( - 'inbound_code', - 'measure_review_status', - 'fee_review_status', - 'expected_arrival_at', - 'chargeable_weight_kg', - 'chargeable_volume_cbm' - )) - OR (TABLE_SCHEMA = 'baiyue_warehouse' - AND TABLE_NAME = 'wh_location_reservation' - AND COLUMN_NAME = 'active_reservation') - OR (TABLE_SCHEMA = 'baiyue_warehouse' - AND TABLE_NAME = 'wh_location_occupancy' - AND COLUMN_NAME = 'active_occupancy'); - -SELECT '二期入库表结构执行完成,请确认以上检查全部为PASS' AS execution_message; diff --git a/deploy/sql/2026-08-04-二期TEST环境增量-MySQL8直接执行.sql b/deploy/sql/2026-08-04-二期TEST环境增量-MySQL8直接执行.sql deleted file mode 100644 index c8c674ecd..000000000 --- a/deploy/sql/2026-08-04-二期TEST环境增量-MySQL8直接执行.sql +++ /dev/null @@ -1,1341 +0,0 @@ --- 柏悦头程二期 TEST 环境增量:MySQL 8 直接执行脚本 --- 生成日期:2026-08-04 --- 适用版本:MySQL 8.0+(需要 GENERATED COLUMN、JSON、CHECK 约束支持) --- 环境边界:仅用于 TEST 联调环境,不包含生产环境连接信息。 --- 数据库名:baiyue_file、baiyue_firstleg、baiyue_warehouse。 --- 执行方式:在 TEST MySQL 连接中完整运行本文件,不要拆分执行。 --- 适用场景: --- 1. 新环境:在一期基线完成后直接执行,创建二期最终表结构。 --- 2. 存量二期环境:可重复执行,自动补齐新字段、索引和 A0 共享溢出约束。 --- 执行范围:仅 CREATE / ALTER 表、字段、索引、约束;不插入权限、不写业务单据。 --- 目标数据库:baiyue_file、baiyue_firstleg、baiyue_warehouse。 --- 前置条件:一期的 baiyue_firstleg.fl_fulfillment_task 与 baiyue_warehouse.wh_warehouse 已存在。 --- 注意: --- - MySQL DDL 自动提交,执行前必须完成备份并在临时/预发布库演练。 --- - 既有数据若已违反即将建立的唯一约束,脚本会停止,需要先治理数据。 --- - 本脚本由 MySQL Client / MySQL Workbench 直接执行;包含 DELIMITER 的存储过程定义。 --- - 不连接、不修改正式库;请在明确指定的目标库连接中执行。 - -SET NAMES utf8mb4; -SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; -SET @deployment_environment := 'TEST'; - -SELECT @deployment_environment AS deployment_environment, - VERSION() AS mysql_version, - '开始执行二期入库新增表与字段脚本' AS execution_message; - -SELECT - EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_fulfillment_task') AS has_phase1_fulfillment_task, - EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse') AS has_phase1_warehouse; - --- ============================================================================ --- 文件中心基线(测量图片依赖) --- 来源:file/src/main/resources/db/file.sql --- ============================================================================ --- 文件中心独占数据库,其他服务只保存文件逻辑ID。 -CREATE DATABASE IF NOT EXISTS baiyue_file - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_file; - -CREATE TABLE IF NOT EXISTS file_object ( - id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - business_type VARCHAR(32) NULL COMMENT '关联业务类型', - business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', - original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', - object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', - storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方', - bucket_name VARCHAR(128) NOT NULL COMMENT '存储桶名称', - content_type VARCHAR(128) NOT NULL COMMENT '文件内容类型', - size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件字节数', - sha256 CHAR(64) NULL COMMENT '文件SHA-256摘要', - status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' 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_file_tenant_object (tenant_id, object_key), - KEY idx_file_tenant_business (tenant_id, business_type, business_id), - KEY idx_file_tenant_created (tenant_id, created_at) -) ENGINE=InnoDB COMMENT='文件元数据表'; - -CREATE TABLE IF NOT EXISTS file_download_token ( - id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', - token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要', - expires_at DATETIME(3) NOT NULL COMMENT '过期时间', - max_downloads INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '最大下载次数', - download_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已下载次数', - revoked_at DATETIME(3) NULL COMMENT '撤销时间', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_download_token_hash (token_hash), - KEY idx_download_file (tenant_id, file_id), - KEY idx_download_expiry (expires_at) -) ENGINE=InnoDB COMMENT='临时下载授权令牌表'; - -CREATE TABLE IF NOT EXISTS file_download_log ( - id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', - user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID', - request_id VARCHAR(64) NULL COMMENT '请求ID', - client_ip VARCHAR(64) NULL COMMENT '客户端IP', - result VARCHAR(16) NOT NULL COMMENT '下载结果', - failure_reason VARCHAR(500) NULL COMMENT '失败原因', - downloaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下载时间', - PRIMARY KEY (id), - KEY idx_download_log_file (tenant_id, file_id, downloaded_at), - KEY idx_download_log_user (tenant_id, user_id, downloaded_at) -) ENGINE=InnoDB COMMENT='文件下载审计日志表'; - - --- ============================================================================ --- Firstleg 二期最终基线表 --- 来源:firstleg/src/main/resources/db/phase2-inbound-schema.sql --- ============================================================================ -SET NAMES utf8mb4; - -CREATE DATABASE IF NOT EXISTS baiyue_firstleg - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_firstleg; - -CREATE TABLE IF NOT EXISTS fl_inbound_order ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', - order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', - order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', - inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码', - process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态', - order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', - measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态', - fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态', - fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', - notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态', - expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', - customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID', - company_name VARCHAR(128) NULL COMMENT '运营公司名称快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID', - warehouse_name VARCHAR(128) NOT NULL COMMENT '实际作业仓库名称快照', - delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', - delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型', - channel_code VARCHAR(64) NULL COMMENT '渠道编码', - channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', - carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', - carrier_name VARCHAR(128) NULL COMMENT '计费船司名称快照', - tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式', - container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式', - customs_inspection TINYINT(1) NULL COMMENT '是否商检', - destination_type VARCHAR(24) NULL COMMENT '目的仓类型', - destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID', - destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码', - destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', - hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', - current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', - total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', - actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', - chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', - actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', - chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', - pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照', - volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg', - actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg', - measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间', - inbounded_at DATETIME(3) NULL COMMENT '入库完成时间', - express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照', - express_company_name VARCHAR(128) NULL COMMENT '快递公司名称快照', - express_tracking_no VARCHAR(128) NULL COMMENT '快递单号快照', - public_remark VARCHAR(1000) NULL COMMENT '客户可见备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', - 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_inbound_tenant_code (tenant_id, inbound_code), - UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), - KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), - KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), - KEY idx_inbound_fee_time (tenant_id, fee_review_status, created_at, id), - KEY idx_inbound_warehouse_status (tenant_id, warehouse_id, process_status, created_at), - KEY idx_inbound_customer_time (tenant_id, customer_id, created_at), - KEY idx_inbound_company_time (tenant_id, company_id, created_at), - KEY idx_inbound_arrival (tenant_id, expected_arrival_at), - KEY idx_inbound_destination (tenant_id, destination_code, created_at), - KEY idx_inbound_channel (tenant_id, channel_code, created_at), - CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) -) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - pickup_method VARCHAR(24) NULL COMMENT '提货方式', - pickup_at DATETIME(3) NULL COMMENT '提货时间', - contact_name VARCHAR(64) NULL COMMENT '提货联系人', - contact_phone VARCHAR(32) NULL COMMENT '提货联系人电话', - contact_email VARCHAR(128) NULL COMMENT '提货联系人邮箱', - country_code CHAR(2) NULL COMMENT '国家二字码', - province VARCHAR(64) NULL COMMENT '省或州', - city VARCHAR(64) NULL COMMENT '城市', - district VARCHAR(64) NULL COMMENT '区县', - address_line VARCHAR(512) NULL COMMENT '提货详细地址', - postal_code VARCHAR(20) NULL COMMENT '提货邮编', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_pickup_inbound (tenant_id, inbound_id), - CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单提货信息不可变快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - destination_type VARCHAR(24) NULL COMMENT '目的仓类型', - destination_code VARCHAR(32) NULL COMMENT '目的仓编码', - destination_name VARCHAR(128) NULL COMMENT '目的仓名称', - country_code CHAR(2) NULL COMMENT '国家二字码', - state_province VARCHAR(100) NULL COMMENT '省或州', - city VARCHAR(100) NULL COMMENT '城市', - address_line VARCHAR(512) NULL COMMENT '详细地址', - postal_code VARCHAR(20) NULL COMMENT '邮编', - contact_name VARCHAR(64) NULL COMMENT '联系人', - contact_phone VARCHAR(32) NULL COMMENT '联系人电话', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_destination_inbound (tenant_id, inbound_id), - CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单目的仓地址和联系人快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - loading_status VARCHAR(24) NULL COMMENT '配载状态快照', - container_no VARCHAR(64) NULL COMMENT '集装箱号', - cabinet_no VARCHAR(64) NULL COMMENT '柜号或内部柜单号', - vessel_name VARCHAR(128) NULL COMMENT '船名', - voyage_no VARCHAR(64) NULL COMMENT '航次', - sailing_at DATETIME(3) NULL COMMENT '开船时间', - port_arrival_at DATETIME(3) NULL COMMENT '到港时间', - source_updated_at DATETIME(3) NULL COMMENT '上游数据更新时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_transport_inbound (tenant_id, inbound_id), - KEY idx_transport_container (tenant_id, container_no), - KEY idx_transport_cabinet (tenant_id, cabinet_no), - KEY idx_transport_loading (tenant_id, loading_status, sailing_at), - CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_measurement ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL', - source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码', - external_event_no VARCHAR(128) NULL COMMENT '第三方事件号', - device_code VARCHAR(64) NULL COMMENT '测量设备编码', - warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码', - measured_at DATETIME(3) NULL COMMENT '设备测量时间', - status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态', - raw_payload_json JSON NULL COMMENT '第三方原始报文', - original_document_no VARCHAR(64) NULL COMMENT '原单据号码快照', - original_gross_weight_kg DECIMAL(18,3) NULL COMMENT '原单据毛重kg', - original_volume_cbm DECIMAL(18,4) NULL COMMENT '原单据体积或材积m3', - actual_goods_name VARCHAR(255) NULL COMMENT '实际品名', - total_package_count INT UNSIGNED NULL COMMENT '本批次总箱数', - total_volume_raw_cbm DECIMAL(18,6) NULL COMMENT '本批次原始总体积m3', - total_volume_cbm DECIMAL(18,4) NULL COMMENT '本批次业务总体积m3', - volume_weight_kg DECIMAL(18,3) NULL COMMENT '本批次总材积重kg', - created_by BIGINT UNSIGNED NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - PRIMARY KEY (id), - UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no), - KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at), - CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据'; - -CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', - item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号', - box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识', - package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数', - length_cm DECIMAL(12,3) NOT NULL COMMENT '原始长度cm', - width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm', - height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm', - girth_cm DECIMAL(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', - unit_weight_kg DECIMAL(18,3) NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', - unit_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '单箱业务体积m3', - volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3', - volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3', - total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', - total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', - volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', - calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', - source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', - over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照', - outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm', - outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm', - outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm', - remark VARCHAR(500) NULL COMMENT '测量明细备注', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no), - CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) -) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细'; - -CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( - id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', - ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', - ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', - ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', - ocr_weight_unit VARCHAR(8) NULL COMMENT 'OCR识别原始单位', - confidence DECIMAL(6,5) NULL COMMENT '识别置信度', - adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', - adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', - recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态', - adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', - adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', - adjusted_at DATETIME(3) NULL COMMENT '修正时间', - confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', - created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 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_weight_inbound_file (tenant_id, inbound_id, file_id), - KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), - CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; - -CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( - id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', - revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', - action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', - before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', - before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', - after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', - after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', - reason VARCHAR(500) NULL COMMENT '人工修正原因', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY (id), - UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), - KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), - CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) -) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史'; - -CREATE TABLE IF NOT EXISTS fl_measure_review_record ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID', - review_result VARCHAR(24) NOT NULL COMMENT '审核结果', - review_reason VARCHAR(1000) NULL COMMENT '审核或驳回原因', - exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常放行', - release_reason VARCHAR(1000) NULL COMMENT '异常放行原因', - allow_rejected_config TINYINT(1) NOT NULL DEFAULT 0 COMMENT '操作时Nacos异常放行开关值', - reviewer_id BIGINT UNSIGNED NOT NULL COMMENT '审核人ID', - reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照', - reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间', - PRIMARY KEY (id), - KEY idx_review_inbound_time (tenant_id, inbound_id, reviewed_at), - KEY idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at), - CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) -) ENGINE=InnoDB COMMENT='测量审核、驳回和异常放行不可变记录'; - -CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( - id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码', - rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型', - charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', - tax_mode VARCHAR(24) NULL COMMENT '包税或不包税类型', - conversion_factor DECIMAL(18,6) NOT NULL COMMENT '重量体积换算系数', - rounding_scale TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '结果保留小数位数', - rounding_mode VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式', - currency CHAR(3) NOT NULL COMMENT '默认币种', - effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', - effective_to DATETIME(3) NULL COMMENT '生效结束时间', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态', - remark VARCHAR(500) 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_charge_rule_version (tenant_id, rule_code, rule_version), - KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to) -) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; - -CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( - id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', - rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', - origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', - origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型', - channel_code VARCHAR(64) NULL COMMENT '渠道编码', - carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID', - carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', - destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码', - charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM', - unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价', - currency CHAR(3) NOT NULL COMMENT '币种', - additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费', - effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', - effective_to DATETIME(3) NULL COMMENT '生效结束时间', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态', - remark VARCHAR(500) 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_fba_rate_version (tenant_id, rate_code, rate_version), - KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to) -) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'; - -CREATE TABLE IF NOT EXISTS fl_inbound_fee ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号', - fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', - charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', - charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照', - carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', - rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', - tax_mode VARCHAR(24) NULL COMMENT '包税模式快照', - rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照', - rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照', - rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照', - rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照', - rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照', - origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照', - origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', - channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照', - carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照', - carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', - destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照', - rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照', - rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照', - charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', - conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照', - actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', - actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', - converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', - chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', - charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', - currency CHAR(3) NOT NULL COMMENT '币种', - subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额', - additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额', - total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额', - fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态', - review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', - exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成', - calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID', - calculated_at DATETIME(3) NOT NULL COMMENT '试算时间', - confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', - sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID', - sent_at DATETIME(3) NULL COMMENT '发送客户时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_fee_no (tenant_id, fee_no), - UNIQUE KEY uk_inbound_fee_version (tenant_id, inbound_id, fee_version), - KEY idx_inbound_fee_status (tenant_id, inbound_id, fee_status, review_status), - CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id), - CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) -) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( - id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID', - item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号', - fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码', - fee_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', - quantity DECIMAL(18,4) NOT NULL COMMENT '计费数量', - unit VARCHAR(16) NOT NULL COMMENT '计费单位', - unit_price DECIMAL(18,6) NOT NULL COMMENT '单价', - amount DECIMAL(18,2) NOT NULL COMMENT '金额', - currency CHAR(3) NOT NULL COMMENT '币种', - automatic_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动计算', - remark VARCHAR(500) NULL COMMENT '费用备注', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_fee_item_no (tenant_id, fee_id, item_no), - CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id) -) ENGINE=InnoDB COMMENT='入库头程费用项目明细'; - -CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( - id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', - warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', - allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积', - allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', - location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号', - allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), - UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), - KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), - CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; - -CREATE TABLE IF NOT EXISTS fl_inbound_file ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID', - file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT', - file_name_snapshot VARCHAR(255) NOT NULL COMMENT '文件名快照', - visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围', - file_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '业务文件版本', - uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传人ID', - uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', - deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_file_relation (tenant_id, inbound_id, file_id, file_type), - KEY idx_inbound_file_type (tenant_id, inbound_id, file_type, deleted), - CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单与文件中心对象的分类关系'; - -CREATE TABLE IF NOT EXISTS fl_inbound_hold_record ( - id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE', - reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因', - evidence_file_id BIGINT UNSIGNED NULL COMMENT '证据文件ID', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY (id), - KEY idx_hold_inbound_time (tenant_id, inbound_id, operated_at), - CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单扣货和解除扣货不可变历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_internal_note ( - id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - KEY idx_internal_note_time (tenant_id, inbound_id, created_at), - CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单客服内部备注历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_status_history ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - from_status VARCHAR(24) NULL COMMENT '变更前状态', - to_status VARCHAR(24) NOT NULL COMMENT '变更后状态', - action VARCHAR(32) NOT NULL COMMENT '触发动作', - reason VARCHAR(1000) NULL COMMENT '变更原因', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', - PRIMARY KEY (id), - KEY idx_inbound_history_time (tenant_id, inbound_id, occurred_at), - CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单流程状态不可变历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_outbox ( - id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID', - aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', - aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - payload_json JSON NOT NULL COMMENT '事件负载快照', - publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', - retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', - next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', - published_at DATETIME(3) NULL COMMENT '发布时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - KEY idx_inbound_outbox_publish (publish_status, next_retry_at, created_at) -) ENGINE=InnoDB COMMENT='入库事务Outbox事件表'; - -CREATE TABLE IF NOT EXISTS fl_inbound_idempotency ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', - operation VARCHAR(40) NOT NULL COMMENT '操作类型', - operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID', - inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0', - idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键', - request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', - status VARCHAR(16) NOT NULL COMMENT '处理状态', - result_json JSON NULL COMMENT '成功结果摘要', - result_resource_id BIGINT UNSIGNED NULL COMMENT '成功结果资源ID', - completed_at DATETIME(3) NULL COMMENT '完成时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key), - KEY idx_inbound_idempotency_created (created_at) -) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录'; - - --- ============================================================================ --- Firstleg 称重结果审计字段存量补齐 --- 来源:firstleg/src/main/resources/db/phase2-inbound-weight-audit-migration.sql --- ============================================================================ -USE baiyue_firstleg; - -SET @has_created_by := ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'created_by' -); -SET @created_by_sql := IF(@has_created_by = 0, - 'ALTER TABLE fl_inbound_weight_result ADD COLUMN created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''创建人ID'' AFTER confirmed_at', - 'SELECT 1'); -PREPARE created_by_stmt FROM @created_by_sql; -EXECUTE created_by_stmt; -DEALLOCATE PREPARE created_by_stmt; - -SET @has_updated_by := ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'updated_by' -); -SET @updated_by_sql := IF(@has_updated_by = 0, - 'ALTER TABLE fl_inbound_weight_result ADD COLUMN updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最后修改人ID'' AFTER created_at', - 'SELECT 1'); -PREPARE updated_by_stmt FROM @updated_by_sql; -EXECUTE updated_by_stmt; -DEALLOCATE PREPARE updated_by_stmt; - - --- ============================================================================ --- Firstleg 测量与 OCR 存量字段补齐 --- 来源:firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql --- ============================================================================ --- 二期入库测量与 OCR 维护增量(只增不删,可重复执行)。 --- 执行顺序:phase2-inbound-schema.sql 后执行本文件;已有二期库只需执行本文件。 -SET NAMES utf8mb4; -USE baiyue_firstleg; - -DROP PROCEDURE IF EXISTS phase2_measure_add_column; -DELIMITER $$ -CREATE PROCEDURE phase2_measure_add_column( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_column_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @phase2_measure_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition - ); - PREPARE phase2_measure_statement FROM @phase2_measure_sql; - EXECUTE phase2_measure_statement; - DEALLOCATE PREPARE phase2_measure_statement; - END IF; -END$$ -DELIMITER ; - -CALL phase2_measure_add_column('fl_inbound_measurement', 'original_document_no', - 'VARCHAR(64) NULL COMMENT ''原单据号码快照'' AFTER raw_payload_json'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'original_gross_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''原单据毛重kg'' AFTER original_document_no'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'original_volume_cbm', - 'DECIMAL(18,4) NULL COMMENT ''原单据体积或材积m3'' AFTER original_gross_weight_kg'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'actual_goods_name', - 'VARCHAR(255) NULL COMMENT ''实际品名'' AFTER original_volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'volume_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''本批次总材积重kg'' AFTER total_volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'version', - 'INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号'' AFTER updated_at'); - -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification', - 'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm', - 'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm', - 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm', - 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg', - 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning', - 'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_length_cm', - 'DECIMAL(12,3) NULL COMMENT ''外箱规长度cm'' AFTER over_limit_warning'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', - 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', - 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); - -DROP PROCEDURE IF EXISTS phase2_measure_add_column; - -CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( - id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', - revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', - action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', - before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', - before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', - after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', - after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', - reason VARCHAR(500) NULL COMMENT '人工修正原因', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY (id), - UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), - KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), - CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) -) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史'; - - --- ============================================================================ --- Firstleg 审核工作流索引补齐 --- 来源:firstleg/src/main/resources/db/phase2-inbound-workflow-migration.sql --- ============================================================================ --- 业务块4增量迁移:既有二期库补齐异常放行关口查询索引。 --- 可重复执行;新库仍先执行 phase2-inbound-schema.sql。 -USE baiyue_firstleg; -SET NAMES utf8mb4; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.statistics - WHERE table_schema = DATABASE() AND table_name = 'fl_measure_review_record' - AND index_name = 'idx_review_exception_release'), - 'SELECT 1', - 'ALTER TABLE fl_measure_review_record ADD INDEX idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at)' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - - --- ============================================================================ --- Firstleg 第三方测量调用日志 --- 来源:firstleg/src/main/resources/db/phase2-measurement-openapi.sql --- ============================================================================ -SET NAMES utf8mb4; -USE baiyue_firstleg; - --- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。 -CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( - id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0', - app_key VARCHAR(128) NULL COMMENT '第三方应用标识', - signature_subject VARCHAR(128) NULL COMMENT '签名主体快照', - request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法', - request_path VARCHAR(255) NOT NULL COMMENT '签名使用的规范路径', - request_timestamp VARCHAR(32) NULL COMMENT '请求头Unix毫秒时间戳原值', - nonce VARCHAR(128) NULL COMMENT '请求Nonce', - idempotency_key VARCHAR(128) NULL COMMENT 'HTTP幂等键,查询接口为空', - idempotency_anchor TINYINT UNSIGNED NULL COMMENT '签名通过后首个请求为1,重复调用为空', - signature_value VARCHAR(128) NULL COMMENT '第三方签名原值', - request_hash CHAR(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制', - raw_payload LONGTEXT NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体', - processing_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED', - result_code VARCHAR(32) NULL COMMENT '统一响应业务码', - result_message VARCHAR(1000) NULL COMMENT '处理结果或失败原因', - result_json JSON NULL COMMENT '首次成功结果快照', - requested_at DATETIME(3) NOT NULL COMMENT '服务端接收时间', - processed_at DATETIME(3) NULL COMMENT '处理完成时间', - PRIMARY KEY (id), - KEY idx_third_call_app_time (tenant_id, app_key, requested_at), - KEY idx_third_call_idempotency (tenant_id, app_key, idempotency_key), - KEY idx_third_call_nonce (tenant_id, app_key, nonce), - UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor) -) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; - - --- ============================================================================ --- Firstleg FBA 计费字段与索引补齐 --- 来源:firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql --- ============================================================================ -SET NAMES utf8mb4; -USE baiyue_firstleg; - --- 二期 FBA 计费增量迁移。通过 information_schema 判断,允许重复部署。 -DROP PROCEDURE IF EXISTS phase2_fba_add_column; -DELIMITER $$ -CREATE PROCEDURE phase2_fba_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column - ) THEN - SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); - PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - END IF; -END$$ -DELIMITER ; - -CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID' AFTER channel_name"); -CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id"); -CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name"); -CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); -CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); -CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id"); -CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name"); -CALL phase2_fba_add_column('fl_inbound_fee', 'tax_mode', "VARCHAR(24) NULL COMMENT '包税模式快照' AFTER business_type"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_scale', "TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '计费量保留位数快照' AFTER tax_mode"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_mode', "VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式快照' AFTER rounding_scale"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户规则生效开始快照' AFTER rounding_mode"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_to', "DATETIME(3) NULL COMMENT '客户规则生效结束快照' AFTER rule_effective_from"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rate_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '船司价格编码快照' AFTER rule_effective_to"); -CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '起运仓ID快照' AFTER rate_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '起运仓名称快照' AFTER origin_warehouse_id"); -CALL phase2_fba_add_column('fl_inbound_fee', 'channel_code', "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道编码快照' AFTER origin_warehouse_name"); -CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '船司ID快照' AFTER channel_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '船司名称快照' AFTER carrier_id"); -CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); -CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); -CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', - "BIGINT UNSIGNED NULL COMMENT '仓储资源所属租户ID' AFTER warehouse_id"); - -UPDATE fl_inbound_pallet_allocation -SET warehouse_resource_tenant_id = tenant_id -WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id = 0; -SET @resource_tenant_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_pallet_allocation' - AND COLUMN_NAME = 'warehouse_resource_tenant_id'); -SET @sql = IF(@resource_tenant_nullable = 'YES', - "ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID'", - 'SELECT 1'); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -DROP PROCEDURE IF EXISTS phase2_fba_add_column; - --- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 -SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match'); -SET @sql = IF(@idx_exists = 0, - 'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)', - 'SELECT 1'); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - - --- ============================================================================ --- Warehouse 二期托盘库位最终基线表 --- 来源:warehouse/src/main/resources/db/phase2-pallet-location-schema.sql --- ============================================================================ --- 二期托盘、库位、预占与占用表结构。 --- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。 --- 可在 warehouse.sql 和一期增量脚本之后执行;所有建表均可重复执行。 -SET NAMES utf8mb4; - -CREATE DATABASE IF NOT EXISTS baiyue_warehouse - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_warehouse; - -CREATE TABLE IF NOT EXISTS wh_pallet ( - id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码', - pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', - pallet_name VARCHAR(100) NULL COMMENT '托盘名称', - capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', - remark VARCHAR(500) 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_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), - CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; - -CREATE TABLE IF NOT EXISTS wh_location ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - zone_code VARCHAR(50) NULL COMMENT '库区编码', - zone_name VARCHAR(100) NULL COMMENT '库区名称', - location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码', - location_name VARCHAR(100) NULL COMMENT '库位名称', - location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET', - max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截', - shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为系统共享溢出位A0', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED', - remark VARCHAR(500) 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_location_tenant_warehouse_code (tenant_id, warehouse_id, location_code), - KEY idx_location_available (tenant_id, warehouse_id, status, deleted), - KEY idx_location_zone (tenant_id, warehouse_id, zone_code, status, deleted), - CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT ck_location_max_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) -) ENGINE=InnoDB COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验'; - -CREATE TABLE IF NOT EXISTS wh_location_reservation ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号', - idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', - source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', - business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', - inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', - reservation_status VARCHAR(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED', - expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间', - confirmed_at DATETIME(3) NULL COMMENT '确认占用时间', - released_at DATETIME(3) NULL COMMENT '释放时间', - release_reason VARCHAR(500) NULL COMMENT '释放或过期原因', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记', - active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', - PRIMARY KEY (id), - UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), - UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), - UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), - KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), - KEY idx_location_reservation_expire (reservation_status, expires_at), - KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), - CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), - CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; - -CREATE TABLE IF NOT EXISTS wh_location_occupancy ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号', - reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID', - source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', - business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', - inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', - allocated_volume_cbm DECIMAL(18,6) NOT NULL COMMENT '本托盘分配体积CBM', - allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', - occupancy_status VARCHAR(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED', - occupied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间', - released_at DATETIME(3) NULL COMMENT '释放时间', - release_reason VARCHAR(500) NULL COMMENT '释放原因', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', - active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', - PRIMARY KEY (id), - UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), - UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), - UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), - KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), - KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), - CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), - CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), - CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), - CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; - --- 头程入库单属于其他数据库,不建立跨库外键。 - - --- ============================================================================ --- Warehouse 托盘活动预占唯一约束补齐 --- 来源:warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql --- ============================================================================ --- 二期托盘、库位表的可重复执行增量迁移。 --- 执行顺序:phase2-pallet-location-schema.sql 之后。 -SET NAMES utf8mb4; -USE baiyue_warehouse; - --- 活动预占必须同时保证“一托一位”;A0 共享只放宽库位,不放宽托盘唯一性。 -SET @schema_name = DATABASE(); -DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing; -DELIMITER $$ -CREATE PROCEDURE add_pallet_reservation_column_if_missing() -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'wh_location_reservation' - AND column_name = 'active_pallet_reservation' - ) THEN - ALTER TABLE wh_location_reservation - ADD COLUMN active_pallet_reservation TINYINT - GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) - STORED COMMENT '托盘活动预占唯一标记(包含A0)'; - END IF; -END$$ -DELIMITER ; -CALL add_pallet_reservation_column_if_missing(); -DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = @schema_name - AND table_name = 'wh_location_reservation' - AND index_name = 'uk_pallet_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', - 'SELECT 1'); -PREPARE statement FROM @sql; -EXECUTE statement; -DEALLOCATE PREPARE statement; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = @schema_name - AND table_name = 'wh_location_reservation' - AND index_name = 'uk_pallet_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', - 'SELECT 1'); -PREPARE statement FROM @sql; -EXECUTE statement; -DEALLOCATE PREPARE statement; - - --- ============================================================================ --- Warehouse A0 共享溢出位与最终并发约束 --- 来源:warehouse/src/main/resources/db/phase2-location-overflow-migration.sql --- ============================================================================ --- 二期库位共享溢出位 A0 增量迁移。 --- 执行顺序:phase2-pallet-location-schema.sql、本文件。 --- 普通库位仍保持一托一位;同一租户仓库只有一个 A0,且 A0 可承载多个活动资源。 -SET NAMES utf8mb4; -USE baiyue_warehouse; - -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; -DELIMITER $$ -CREATE PROCEDURE add_location_overflow_column_if_missing( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_column_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @column_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition - ); - PREPARE column_statement FROM @column_sql; - EXECUTE column_statement; - DEALLOCATE PREPARE column_statement; - END IF; -END$$ -DELIMITER ; - -CALL add_location_overflow_column_if_missing( - 'wh_location', 'shared_overflow', - 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否为系统共享溢出位 A0'' AFTER max_weight_kg' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_reservation', 'shared_overflow', - 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'shared_overflow', - 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' -); - --- 兼容迁移前人工或旧流程已创建的 A0,并将资源快照回填一致。 -UPDATE wh_location - SET shared_overflow = 1, - status = 'AVAILABLE' - WHERE location_code = 'A0' AND deleted = 0; - -UPDATE wh_location_reservation r -JOIN wh_location l - ON l.tenant_id = r.tenant_id AND l.id = r.location_id - SET r.shared_overflow = l.shared_overflow; - -UPDATE wh_location_occupancy o -JOIN wh_location l - ON l.tenant_id = o.tenant_id AND l.id = o.location_id - SET o.shared_overflow = l.shared_overflow; - -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; - --- 先移除依赖旧生成列的唯一索引,再按普通库位/A0 的不同语义重建。 -SET @schema_name = DATABASE(); -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -ALTER TABLE wh_location_reservation - MODIFY COLUMN active_reservation TINYINT - GENERATED ALWAYS AS ( - CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END - ) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL'; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0, - 'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, - 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; -DELIMITER $$ -CREATE PROCEDURE add_location_overflow_column_if_missing( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_column_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @column_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition - ); - PREPARE column_statement FROM @column_sql; - EXECUTE column_statement; - DEALLOCATE PREPARE column_statement; - END IF; -END$$ -DELIMITER ; - -CALL add_location_overflow_column_if_missing( - 'wh_location_reservation', 'active_pallet_reservation', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)''' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'active_location_occupancy', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记''' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'active_pallet_occupancy', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)''' -); -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0, - 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0, - 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - - --- 第三方单条箱规 + 单张图片绑定增量。 -USE baiyue_firstleg; -DROP PROCEDURE IF EXISTS phase2_add_measurement_column; -DELIMITER $$ -CREATE PROCEDURE phase2_add_measurement_column( - IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column - ) THEN - SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); - PREPARE statement FROM @ddl; - EXECUTE statement; - DEALLOCATE PREPARE statement; - END IF; -END$$ -DELIMITER ; -CALL phase2_add_measurement_column('fl_inbound_measurement', 'external_request_hash', - 'CHAR(64) NULL COMMENT ''第三方data原文和图片内容联合请求哈希'' AFTER external_event_no'); -CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'inbound_detail_id', - 'BIGINT UNSIGNED NULL COMMENT ''绑定的订单货物明细ID,平台手工明细可为空'' AFTER measurement_id'); -CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'external_detail_no', - 'VARCHAR(128) NULL COMMENT ''第三方测量明细唯一号,平台手工明细可为空'' AFTER inbound_detail_id'); -DROP PROCEDURE IF EXISTS phase2_add_measurement_column; -SET @has_index = (SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_measurement_item' - AND INDEX_NAME = 'uk_measure_external_detail'); -SET @ddl = IF(@has_index = 0, - 'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no)', - 'SELECT 1'); -PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; - --- 结构核对:只读输出,不写业务数据。 -SELECT 'firstleg 二期核心表数量' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 21, 'PASS', 'FAIL') AS check_result -FROM information_schema.tables -WHERE table_schema = 'baiyue_firstleg' - AND table_name IN ( - 'fl_inbound_order', 'fl_inbound_pickup_snapshot', 'fl_inbound_destination_snapshot', - 'fl_inbound_transport_snapshot', 'fl_inbound_measurement', 'fl_inbound_measurement_item', - 'fl_inbound_weight_result', 'fl_inbound_weight_revision', 'fl_measure_review_record', - 'fl_customer_charge_rule', 'fl_carrier_fba_rate', 'fl_inbound_fee', 'fl_inbound_fee_item', - 'fl_inbound_pallet_allocation', 'fl_inbound_file', 'fl_inbound_hold_record', - 'fl_inbound_internal_note', 'fl_inbound_status_history', 'fl_inbound_outbox', - 'fl_inbound_idempotency', 'fl_third_party_call_log' - ) -UNION ALL -SELECT 'warehouse 二期核心表数量', - COUNT(*), - IF(COUNT(*) = 4, 'PASS', 'FAIL') -FROM information_schema.tables -WHERE table_schema = 'baiyue_warehouse' - AND table_name IN ('wh_pallet', 'wh_location', 'wh_location_reservation', 'wh_location_occupancy') -UNION ALL -SELECT 'file 图片依赖表数量', - COUNT(*), - IF(COUNT(*) = 3, 'PASS', 'FAIL') -FROM information_schema.tables -WHERE table_schema = 'baiyue_file' - AND table_name IN ('file_object', 'file_download_token', 'file_download_log'); - -SELECT 'A0 共享溢出与活动占用关键列' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 6, 'PASS', 'FAIL') AS check_result -FROM information_schema.columns -WHERE (table_schema = 'baiyue_warehouse' AND table_name = 'wh_location' AND column_name = 'shared_overflow') - OR (table_schema = 'baiyue_warehouse' AND table_name = 'wh_location_reservation' - AND column_name IN ('shared_overflow', 'active_reservation', 'active_pallet_reservation')) - OR (table_schema = 'baiyue_warehouse' AND table_name = 'wh_location_occupancy' - AND column_name IN ('shared_overflow', 'active_location_occupancy', 'active_pallet_occupancy')); - -SELECT 'A0 共享溢出与活动占用关键唯一索引' AS check_item, - COUNT(DISTINCT CONCAT(table_schema, '.', table_name, '.', index_name)) AS actual_value, - IF(COUNT(DISTINCT CONCAT(table_schema, '.', table_name, '.', index_name)) = 4, 'PASS', 'FAIL') AS check_result -FROM information_schema.statistics -WHERE table_schema = 'baiyue_warehouse' - AND ( - (table_name = 'wh_location_reservation' AND index_name IN ('uk_location_active_reservation', 'uk_pallet_active_reservation')) - OR (table_name = 'wh_location_occupancy' AND index_name IN ('uk_location_active_occupancy', 'uk_pallet_active_occupancy')) - ); - -SELECT '第三方单条测量绑定关键列' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 3, 'PASS', 'FAIL') AS check_result -FROM information_schema.columns -WHERE table_schema = 'baiyue_firstleg' - AND ((table_name = 'fl_inbound_measurement' AND column_name = 'external_request_hash') - OR (table_name = 'fl_inbound_measurement_item' - AND column_name IN ('inbound_detail_id', 'external_detail_no'))); - -SELECT '第三方单条测量绑定唯一索引' AS check_item, - COUNT(DISTINCT index_name) AS actual_value, - IF(COUNT(DISTINCT index_name) = 1, 'PASS', 'FAIL') AS check_result -FROM information_schema.statistics -WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_measurement_item' - AND index_name = 'uk_measure_external_detail'; - -SELECT '称重结果审计关键列' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 2, 'PASS', 'FAIL') AS check_result -FROM information_schema.columns -WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_weight_result' - AND column_name IN ('created_by', 'updated_by'); - -SELECT @deployment_environment AS deployment_environment, - 'PHASE2_TEST_MIGRATION_COMPLETED' AS execution_code, - '二期 TEST 环境增量脚本执行完成,请确认以上检查全部为 PASS' AS execution_message; diff --git a/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql b/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql deleted file mode 100644 index 6478d11e8..000000000 --- a/deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql +++ /dev/null @@ -1,1334 +0,0 @@ --- 柏悦头程二期入库新增表与字段:MySQL 8 一键执行脚本 --- 生成日期:2026-08-04 --- 适用版本:MySQL 8.0+(需要 GENERATED COLUMN、JSON、CHECK 约束支持) --- 适用场景: --- 1. 新环境:在一期基线完成后直接执行,创建二期最终表结构。 --- 2. 存量二期环境:可重复执行,自动补齐新字段、索引和 A0 共享溢出约束。 --- 执行范围:仅 CREATE / ALTER 表、字段、索引、约束;不插入权限、不写业务单据。 --- 目标数据库:baiyue_file、baiyue_firstleg、baiyue_warehouse。 --- 前置条件:一期的 baiyue_firstleg.fl_fulfillment_task 与 baiyue_warehouse.wh_warehouse 已存在。 --- 注意: --- - MySQL DDL 自动提交,执行前必须完成备份并在临时/预发布库演练。 --- - 既有数据若已违反即将建立的唯一约束,脚本会停止,需要先治理数据。 --- - 本脚本由 MySQL Client / MySQL Workbench 直接执行;包含 DELIMITER 的存储过程定义。 --- - 不连接、不修改正式库;请在明确指定的目标库连接中执行。 - -SET NAMES utf8mb4; -SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; - -SELECT VERSION() AS mysql_version, - '开始执行二期入库新增表与字段脚本' AS execution_message; - -SELECT - EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_fulfillment_task') AS has_phase1_fulfillment_task, - EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = 'baiyue_warehouse' AND table_name = 'wh_warehouse') AS has_phase1_warehouse; - --- ============================================================================ --- 文件中心基线(测量图片依赖) --- 来源:file/src/main/resources/db/file.sql --- ============================================================================ --- 文件中心独占数据库,其他服务只保存文件逻辑ID。 -CREATE DATABASE IF NOT EXISTS baiyue_file - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_file; - -CREATE TABLE IF NOT EXISTS file_object ( - id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - business_type VARCHAR(32) NULL COMMENT '关联业务类型', - business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', - original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', - object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', - storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方', - bucket_name VARCHAR(128) NOT NULL COMMENT '存储桶名称', - content_type VARCHAR(128) NOT NULL COMMENT '文件内容类型', - size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件字节数', - sha256 CHAR(64) NULL COMMENT '文件SHA-256摘要', - status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' 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_file_tenant_object (tenant_id, object_key), - KEY idx_file_tenant_business (tenant_id, business_type, business_id), - KEY idx_file_tenant_created (tenant_id, created_at) -) ENGINE=InnoDB COMMENT='文件元数据表'; - -CREATE TABLE IF NOT EXISTS file_download_token ( - id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', - token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要', - expires_at DATETIME(3) NOT NULL COMMENT '过期时间', - max_downloads INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '最大下载次数', - download_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已下载次数', - revoked_at DATETIME(3) NULL COMMENT '撤销时间', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_download_token_hash (token_hash), - KEY idx_download_file (tenant_id, file_id), - KEY idx_download_expiry (expires_at) -) ENGINE=InnoDB COMMENT='临时下载授权令牌表'; - -CREATE TABLE IF NOT EXISTS file_download_log ( - id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', - user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID', - request_id VARCHAR(64) NULL COMMENT '请求ID', - client_ip VARCHAR(64) NULL COMMENT '客户端IP', - result VARCHAR(16) NOT NULL COMMENT '下载结果', - failure_reason VARCHAR(500) NULL COMMENT '失败原因', - downloaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下载时间', - PRIMARY KEY (id), - KEY idx_download_log_file (tenant_id, file_id, downloaded_at), - KEY idx_download_log_user (tenant_id, user_id, downloaded_at) -) ENGINE=InnoDB COMMENT='文件下载审计日志表'; - - --- ============================================================================ --- Firstleg 二期最终基线表 --- 来源:firstleg/src/main/resources/db/phase2-inbound-schema.sql --- ============================================================================ -SET NAMES utf8mb4; - -CREATE DATABASE IF NOT EXISTS baiyue_firstleg - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_firstleg; - -CREATE TABLE IF NOT EXISTS fl_inbound_order ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', - order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', - order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', - inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码', - process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态', - order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', - measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态', - fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态', - fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', - notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态', - expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', - customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID', - company_name VARCHAR(128) NULL COMMENT '运营公司名称快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID', - warehouse_name VARCHAR(128) NOT NULL COMMENT '实际作业仓库名称快照', - delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', - delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型', - channel_code VARCHAR(64) NULL COMMENT '渠道编码', - channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', - carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', - carrier_name VARCHAR(128) NULL COMMENT '计费船司名称快照', - tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式', - container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式', - customs_inspection TINYINT(1) NULL COMMENT '是否商检', - destination_type VARCHAR(24) NULL COMMENT '目的仓类型', - destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID', - destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码', - destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', - hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', - current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', - total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', - actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', - chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', - actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', - chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', - pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照', - volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg', - actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg', - measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间', - inbounded_at DATETIME(3) NULL COMMENT '入库完成时间', - express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照', - express_company_name VARCHAR(128) NULL COMMENT '快递公司名称快照', - express_tracking_no VARCHAR(128) NULL COMMENT '快递单号快照', - public_remark VARCHAR(1000) NULL COMMENT '客户可见备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', - 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_inbound_tenant_code (tenant_id, inbound_code), - UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), - KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), - KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), - KEY idx_inbound_fee_time (tenant_id, fee_review_status, created_at, id), - KEY idx_inbound_warehouse_status (tenant_id, warehouse_id, process_status, created_at), - KEY idx_inbound_customer_time (tenant_id, customer_id, created_at), - KEY idx_inbound_company_time (tenant_id, company_id, created_at), - KEY idx_inbound_arrival (tenant_id, expected_arrival_at), - KEY idx_inbound_destination (tenant_id, destination_code, created_at), - KEY idx_inbound_channel (tenant_id, channel_code, created_at), - CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) -) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - pickup_method VARCHAR(24) NULL COMMENT '提货方式', - pickup_at DATETIME(3) NULL COMMENT '提货时间', - contact_name VARCHAR(64) NULL COMMENT '提货联系人', - contact_phone VARCHAR(32) NULL COMMENT '提货联系人电话', - contact_email VARCHAR(128) NULL COMMENT '提货联系人邮箱', - country_code CHAR(2) NULL COMMENT '国家二字码', - province VARCHAR(64) NULL COMMENT '省或州', - city VARCHAR(64) NULL COMMENT '城市', - district VARCHAR(64) NULL COMMENT '区县', - address_line VARCHAR(512) NULL COMMENT '提货详细地址', - postal_code VARCHAR(20) NULL COMMENT '提货邮编', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_pickup_inbound (tenant_id, inbound_id), - CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单提货信息不可变快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - destination_type VARCHAR(24) NULL COMMENT '目的仓类型', - destination_code VARCHAR(32) NULL COMMENT '目的仓编码', - destination_name VARCHAR(128) NULL COMMENT '目的仓名称', - country_code CHAR(2) NULL COMMENT '国家二字码', - state_province VARCHAR(100) NULL COMMENT '省或州', - city VARCHAR(100) NULL COMMENT '城市', - address_line VARCHAR(512) NULL COMMENT '详细地址', - postal_code VARCHAR(20) NULL COMMENT '邮编', - contact_name VARCHAR(64) NULL COMMENT '联系人', - contact_phone VARCHAR(32) NULL COMMENT '联系人电话', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_destination_inbound (tenant_id, inbound_id), - CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单目的仓地址和联系人快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot ( - id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - loading_status VARCHAR(24) NULL COMMENT '配载状态快照', - container_no VARCHAR(64) NULL COMMENT '集装箱号', - cabinet_no VARCHAR(64) NULL COMMENT '柜号或内部柜单号', - vessel_name VARCHAR(128) NULL COMMENT '船名', - voyage_no VARCHAR(64) NULL COMMENT '航次', - sailing_at DATETIME(3) NULL COMMENT '开船时间', - port_arrival_at DATETIME(3) NULL COMMENT '到港时间', - source_updated_at DATETIME(3) NULL COMMENT '上游数据更新时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_transport_inbound (tenant_id, inbound_id), - KEY idx_transport_container (tenant_id, container_no), - KEY idx_transport_cabinet (tenant_id, cabinet_no), - KEY idx_transport_loading (tenant_id, loading_status, sailing_at), - CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_measurement ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL', - source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码', - external_event_no VARCHAR(128) NULL COMMENT '第三方事件号', - device_code VARCHAR(64) NULL COMMENT '测量设备编码', - warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码', - measured_at DATETIME(3) NULL COMMENT '设备测量时间', - status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态', - raw_payload_json JSON NULL COMMENT '第三方原始报文', - original_document_no VARCHAR(64) NULL COMMENT '原单据号码快照', - original_gross_weight_kg DECIMAL(18,3) NULL COMMENT '原单据毛重kg', - original_volume_cbm DECIMAL(18,4) NULL COMMENT '原单据体积或材积m3', - actual_goods_name VARCHAR(255) NULL COMMENT '实际品名', - total_package_count INT UNSIGNED NULL COMMENT '本批次总箱数', - total_volume_raw_cbm DECIMAL(18,6) NULL COMMENT '本批次原始总体积m3', - total_volume_cbm DECIMAL(18,4) NULL COMMENT '本批次业务总体积m3', - volume_weight_kg DECIMAL(18,3) NULL COMMENT '本批次总材积重kg', - created_by BIGINT UNSIGNED NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - PRIMARY KEY (id), - UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no), - KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at), - CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据'; - -CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', - item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号', - box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识', - package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数', - length_cm DECIMAL(12,3) NOT NULL COMMENT '原始长度cm', - width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm', - height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm', - girth_cm DECIMAL(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', - unit_weight_kg DECIMAL(18,3) NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', - unit_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '单箱业务体积m3', - volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3', - volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3', - total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', - total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', - volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', - calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', - source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', - over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照', - outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm', - outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm', - outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm', - remark VARCHAR(500) NULL COMMENT '测量明细备注', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no), - CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) -) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细'; - -CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( - id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', - ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', - ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', - ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', - ocr_weight_unit VARCHAR(8) NULL COMMENT 'OCR识别原始单位', - confidence DECIMAL(6,5) NULL COMMENT '识别置信度', - adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', - adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', - recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态', - adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', - adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', - adjusted_at DATETIME(3) NULL COMMENT '修正时间', - confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', - created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 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_weight_inbound_file (tenant_id, inbound_id, file_id), - KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), - CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; - -CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( - id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', - revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', - action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', - before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', - before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', - after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', - after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', - reason VARCHAR(500) NULL COMMENT '人工修正原因', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY (id), - UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), - KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), - CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) -) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史'; - -CREATE TABLE IF NOT EXISTS fl_measure_review_record ( - id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID', - review_result VARCHAR(24) NOT NULL COMMENT '审核结果', - review_reason VARCHAR(1000) NULL COMMENT '审核或驳回原因', - exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常放行', - release_reason VARCHAR(1000) NULL COMMENT '异常放行原因', - allow_rejected_config TINYINT(1) NOT NULL DEFAULT 0 COMMENT '操作时Nacos异常放行开关值', - reviewer_id BIGINT UNSIGNED NOT NULL COMMENT '审核人ID', - reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照', - reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间', - PRIMARY KEY (id), - KEY idx_review_inbound_time (tenant_id, inbound_id, reviewed_at), - KEY idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at), - CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) -) ENGINE=InnoDB COMMENT='测量审核、驳回和异常放行不可变记录'; - -CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( - id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码', - rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型', - charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', - tax_mode VARCHAR(24) NULL COMMENT '包税或不包税类型', - conversion_factor DECIMAL(18,6) NOT NULL COMMENT '重量体积换算系数', - rounding_scale TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '结果保留小数位数', - rounding_mode VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式', - currency CHAR(3) NOT NULL COMMENT '默认币种', - effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', - effective_to DATETIME(3) NULL COMMENT '生效结束时间', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态', - remark VARCHAR(500) 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_charge_rule_version (tenant_id, rule_code, rule_version), - KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to) -) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; - -CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( - id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', - rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', - origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', - origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型', - channel_code VARCHAR(64) NULL COMMENT '渠道编码', - carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID', - carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', - destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码', - charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM', - unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价', - currency CHAR(3) NOT NULL COMMENT '币种', - additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费', - effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', - effective_to DATETIME(3) NULL COMMENT '生效结束时间', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态', - remark VARCHAR(500) 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_fba_rate_version (tenant_id, rate_code, rate_version), - KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to) -) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'; - -CREATE TABLE IF NOT EXISTS fl_inbound_fee ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号', - fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', - charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', - charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照', - carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', - rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', - business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', - tax_mode VARCHAR(24) NULL COMMENT '包税模式快照', - rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照', - rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照', - rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照', - rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照', - rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照', - origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照', - origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', - channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照', - carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照', - carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', - destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照', - rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照', - rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照', - charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', - conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照', - actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', - actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', - converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', - chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', - charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', - currency CHAR(3) NOT NULL COMMENT '币种', - subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额', - additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额', - total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额', - fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态', - review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', - exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成', - calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID', - calculated_at DATETIME(3) NOT NULL COMMENT '试算时间', - confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', - confirmed_at DATETIME(3) NULL COMMENT '确认时间', - sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID', - sent_at DATETIME(3) NULL COMMENT '发送客户时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_fee_no (tenant_id, fee_no), - UNIQUE KEY uk_inbound_fee_version (tenant_id, inbound_id, fee_version), - KEY idx_inbound_fee_status (tenant_id, inbound_id, fee_status, review_status), - CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id), - CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) -) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; - -CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( - id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID', - item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号', - fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码', - fee_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', - quantity DECIMAL(18,4) NOT NULL COMMENT '计费数量', - unit VARCHAR(16) NOT NULL COMMENT '计费单位', - unit_price DECIMAL(18,6) NOT NULL COMMENT '单价', - amount DECIMAL(18,2) NOT NULL COMMENT '金额', - currency CHAR(3) NOT NULL COMMENT '币种', - automatic_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动计算', - remark VARCHAR(500) NULL COMMENT '费用备注', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_fee_item_no (tenant_id, fee_id, item_no), - CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id) -) ENGINE=InnoDB COMMENT='入库头程费用项目明细'; - -CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( - id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', - warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', - allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积', - allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', - location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号', - allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), - UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), - KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), - CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; - -CREATE TABLE IF NOT EXISTS fl_inbound_file ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID', - file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT', - file_name_snapshot VARCHAR(255) NOT NULL COMMENT '文件名快照', - visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围', - file_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '业务文件版本', - uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传人ID', - uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', - deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_file_relation (tenant_id, inbound_id, file_id, file_type), - KEY idx_inbound_file_type (tenant_id, inbound_id, file_type, deleted), - CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单与文件中心对象的分类关系'; - -CREATE TABLE IF NOT EXISTS fl_inbound_hold_record ( - id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE', - reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因', - evidence_file_id BIGINT UNSIGNED NULL COMMENT '证据文件ID', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY (id), - KEY idx_hold_inbound_time (tenant_id, inbound_id, operated_at), - CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单扣货和解除扣货不可变历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_internal_note ( - id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - KEY idx_internal_note_time (tenant_id, inbound_id, created_at), - CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单客服内部备注历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_status_history ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - from_status VARCHAR(24) NULL COMMENT '变更前状态', - to_status VARCHAR(24) NOT NULL COMMENT '变更后状态', - action VARCHAR(32) NOT NULL COMMENT '触发动作', - reason VARCHAR(1000) NULL COMMENT '变更原因', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', - PRIMARY KEY (id), - KEY idx_inbound_history_time (tenant_id, inbound_id, occurred_at), - CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='入库单流程状态不可变历史'; - -CREATE TABLE IF NOT EXISTS fl_inbound_outbox ( - id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID', - aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', - aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - payload_json JSON NOT NULL COMMENT '事件负载快照', - publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', - retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', - next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', - published_at DATETIME(3) NULL COMMENT '发布时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), - KEY idx_inbound_outbox_publish (publish_status, next_retry_at, created_at) -) ENGINE=InnoDB COMMENT='入库事务Outbox事件表'; - -CREATE TABLE IF NOT EXISTS fl_inbound_idempotency ( - id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', - operation VARCHAR(40) NOT NULL COMMENT '操作类型', - operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID', - inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0', - idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键', - request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', - status VARCHAR(16) NOT NULL COMMENT '处理状态', - result_json JSON NULL COMMENT '成功结果摘要', - result_resource_id BIGINT UNSIGNED NULL COMMENT '成功结果资源ID', - completed_at DATETIME(3) NULL COMMENT '完成时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key), - KEY idx_inbound_idempotency_created (created_at) -) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录'; - - --- ============================================================================ --- Firstleg 称重结果审计字段存量补齐 --- 来源:firstleg/src/main/resources/db/phase2-inbound-weight-audit-migration.sql --- ============================================================================ -USE baiyue_firstleg; - -SET @has_created_by := ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'created_by' -); -SET @created_by_sql := IF(@has_created_by = 0, - 'ALTER TABLE fl_inbound_weight_result ADD COLUMN created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''创建人ID'' AFTER confirmed_at', - 'SELECT 1'); -PREPARE created_by_stmt FROM @created_by_sql; -EXECUTE created_by_stmt; -DEALLOCATE PREPARE created_by_stmt; - -SET @has_updated_by := ( - SELECT COUNT(*) FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'updated_by' -); -SET @updated_by_sql := IF(@has_updated_by = 0, - 'ALTER TABLE fl_inbound_weight_result ADD COLUMN updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最后修改人ID'' AFTER created_at', - 'SELECT 1'); -PREPARE updated_by_stmt FROM @updated_by_sql; -EXECUTE updated_by_stmt; -DEALLOCATE PREPARE updated_by_stmt; - - --- ============================================================================ --- Firstleg 测量与 OCR 存量字段补齐 --- 来源:firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql --- ============================================================================ --- 二期入库测量与 OCR 维护增量(只增不删,可重复执行)。 --- 执行顺序:phase2-inbound-schema.sql 后执行本文件;已有二期库只需执行本文件。 -SET NAMES utf8mb4; -USE baiyue_firstleg; - -DROP PROCEDURE IF EXISTS phase2_measure_add_column; -DELIMITER $$ -CREATE PROCEDURE phase2_measure_add_column( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_column_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @phase2_measure_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition - ); - PREPARE phase2_measure_statement FROM @phase2_measure_sql; - EXECUTE phase2_measure_statement; - DEALLOCATE PREPARE phase2_measure_statement; - END IF; -END$$ -DELIMITER ; - -CALL phase2_measure_add_column('fl_inbound_measurement', 'original_document_no', - 'VARCHAR(64) NULL COMMENT ''原单据号码快照'' AFTER raw_payload_json'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'original_gross_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''原单据毛重kg'' AFTER original_document_no'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'original_volume_cbm', - 'DECIMAL(18,4) NULL COMMENT ''原单据体积或材积m3'' AFTER original_gross_weight_kg'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'actual_goods_name', - 'VARCHAR(255) NULL COMMENT ''实际品名'' AFTER original_volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'volume_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''本批次总材积重kg'' AFTER total_volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement', 'version', - 'INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号'' AFTER updated_at'); - -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification', - 'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm', - 'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm', - 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg', - 'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm', - 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg', - 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning', - 'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_length_cm', - 'DECIMAL(12,3) NULL COMMENT ''外箱规长度cm'' AFTER over_limit_warning'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', - 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); -CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', - 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); - -DROP PROCEDURE IF EXISTS phase2_measure_add_column; - -CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( - id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', - weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', - revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', - action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', - before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', - before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', - after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', - after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', - reason VARCHAR(500) NULL COMMENT '人工修正原因', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', - operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', - PRIMARY KEY (id), - UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), - KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), - CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), - CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) -) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史'; - - --- ============================================================================ --- Firstleg 审核工作流索引补齐 --- 来源:firstleg/src/main/resources/db/phase2-inbound-workflow-migration.sql --- ============================================================================ --- 业务块4增量迁移:既有二期库补齐异常放行关口查询索引。 --- 可重复执行;新库仍先执行 phase2-inbound-schema.sql。 -USE baiyue_firstleg; -SET NAMES utf8mb4; - -SET @ddl = IF( - EXISTS(SELECT 1 FROM information_schema.statistics - WHERE table_schema = DATABASE() AND table_name = 'fl_measure_review_record' - AND index_name = 'idx_review_exception_release'), - 'SELECT 1', - 'ALTER TABLE fl_measure_review_record ADD INDEX idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at)' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - - --- ============================================================================ --- Firstleg 第三方测量调用日志 --- 来源:firstleg/src/main/resources/db/phase2-measurement-openapi.sql --- ============================================================================ -SET NAMES utf8mb4; -USE baiyue_firstleg; - --- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。 -CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( - id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0', - app_key VARCHAR(128) NULL COMMENT '第三方应用标识', - signature_subject VARCHAR(128) NULL COMMENT '签名主体快照', - request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法', - request_path VARCHAR(255) NOT NULL COMMENT '签名使用的规范路径', - request_timestamp VARCHAR(32) NULL COMMENT '请求头Unix毫秒时间戳原值', - nonce VARCHAR(128) NULL COMMENT '请求Nonce', - idempotency_key VARCHAR(128) NULL COMMENT 'HTTP幂等键,查询接口为空', - idempotency_anchor TINYINT UNSIGNED NULL COMMENT '签名通过后首个请求为1,重复调用为空', - signature_value VARCHAR(128) NULL COMMENT '第三方签名原值', - request_hash CHAR(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制', - raw_payload LONGTEXT NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体', - processing_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED', - result_code VARCHAR(32) NULL COMMENT '统一响应业务码', - result_message VARCHAR(1000) NULL COMMENT '处理结果或失败原因', - result_json JSON NULL COMMENT '首次成功结果快照', - requested_at DATETIME(3) NOT NULL COMMENT '服务端接收时间', - processed_at DATETIME(3) NULL COMMENT '处理完成时间', - PRIMARY KEY (id), - KEY idx_third_call_app_time (tenant_id, app_key, requested_at), - KEY idx_third_call_idempotency (tenant_id, app_key, idempotency_key), - KEY idx_third_call_nonce (tenant_id, app_key, nonce), - UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor) -) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; - - --- ============================================================================ --- Firstleg FBA 计费字段与索引补齐 --- 来源:firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql --- ============================================================================ -SET NAMES utf8mb4; -USE baiyue_firstleg; - --- 二期 FBA 计费增量迁移。通过 information_schema 判断,允许重复部署。 -DROP PROCEDURE IF EXISTS phase2_fba_add_column; -DELIMITER $$ -CREATE PROCEDURE phase2_fba_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column - ) THEN - SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); - PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - END IF; -END$$ -DELIMITER ; - -CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID' AFTER channel_name"); -CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id"); -CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name"); -CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); -CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); -CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id"); -CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name"); -CALL phase2_fba_add_column('fl_inbound_fee', 'tax_mode', "VARCHAR(24) NULL COMMENT '包税模式快照' AFTER business_type"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_scale', "TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '计费量保留位数快照' AFTER tax_mode"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_mode', "VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式快照' AFTER rounding_scale"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户规则生效开始快照' AFTER rounding_mode"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_to', "DATETIME(3) NULL COMMENT '客户规则生效结束快照' AFTER rule_effective_from"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rate_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '船司价格编码快照' AFTER rule_effective_to"); -CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '起运仓ID快照' AFTER rate_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '起运仓名称快照' AFTER origin_warehouse_id"); -CALL phase2_fba_add_column('fl_inbound_fee', 'channel_code', "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道编码快照' AFTER origin_warehouse_name"); -CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '船司ID快照' AFTER channel_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '船司名称快照' AFTER carrier_id"); -CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code"); -CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); -CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); -CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', - "BIGINT UNSIGNED NULL COMMENT '仓储资源所属租户ID' AFTER warehouse_id"); - -UPDATE fl_inbound_pallet_allocation -SET warehouse_resource_tenant_id = tenant_id -WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id = 0; -SET @resource_tenant_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_pallet_allocation' - AND COLUMN_NAME = 'warehouse_resource_tenant_id'); -SET @sql = IF(@resource_tenant_nullable = 'YES', - "ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID'", - 'SELECT 1'); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -DROP PROCEDURE IF EXISTS phase2_fba_add_column; - --- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 -SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match'); -SET @sql = IF(@idx_exists = 0, - 'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)', - 'SELECT 1'); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - - --- ============================================================================ --- Warehouse 二期托盘库位最终基线表 --- 来源:warehouse/src/main/resources/db/phase2-pallet-location-schema.sql --- ============================================================================ --- 二期托盘、库位、预占与占用表结构。 --- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。 --- 可在 warehouse.sql 和一期增量脚本之后执行;所有建表均可重复执行。 -SET NAMES utf8mb4; - -CREATE DATABASE IF NOT EXISTS baiyue_warehouse - DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_warehouse; - -CREATE TABLE IF NOT EXISTS wh_pallet ( - id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码', - pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', - pallet_name VARCHAR(100) NULL COMMENT '托盘名称', - capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', - remark VARCHAR(500) 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_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), - CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; - -CREATE TABLE IF NOT EXISTS wh_location ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', - zone_code VARCHAR(50) NULL COMMENT '库区编码', - zone_name VARCHAR(100) NULL COMMENT '库区名称', - location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码', - location_name VARCHAR(100) NULL COMMENT '库位名称', - location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET', - max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截', - shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为系统共享溢出位A0', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED', - remark VARCHAR(500) 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_location_tenant_warehouse_code (tenant_id, warehouse_id, location_code), - KEY idx_location_available (tenant_id, warehouse_id, status, deleted), - KEY idx_location_zone (tenant_id, warehouse_id, zone_code, status, deleted), - CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT ck_location_max_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) -) ENGINE=InnoDB COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验'; - -CREATE TABLE IF NOT EXISTS wh_location_reservation ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号', - idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', - source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', - business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', - inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', - reservation_status VARCHAR(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED', - expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间', - confirmed_at DATETIME(3) NULL COMMENT '确认占用时间', - released_at DATETIME(3) NULL COMMENT '释放时间', - release_reason VARCHAR(500) NULL COMMENT '释放或过期原因', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记', - active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', - PRIMARY KEY (id), - UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), - UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), - UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), - KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), - KEY idx_location_reservation_expire (reservation_status, expires_at), - KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), - CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), - CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; - -CREATE TABLE IF NOT EXISTS wh_location_occupancy ( - id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号', - reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID', - source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', - business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', - inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', - pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', - location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', - location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', - shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', - allocated_volume_cbm DECIMAL(18,6) NOT NULL COMMENT '本托盘分配体积CBM', - allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', - occupancy_status VARCHAR(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED', - occupied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间', - released_at DATETIME(3) NULL COMMENT '释放时间', - release_reason VARCHAR(500) NULL COMMENT '释放原因', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', - active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', - PRIMARY KEY (id), - UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), - UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), - UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), - KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), - KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), - CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), - CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), - CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), - CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), - CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; - --- 头程入库单属于其他数据库,不建立跨库外键。 - - --- ============================================================================ --- Warehouse 托盘活动预占唯一约束补齐 --- 来源:warehouse/src/main/resources/db/phase2-pallet-location-schema-migration.sql --- ============================================================================ --- 二期托盘、库位表的可重复执行增量迁移。 --- 执行顺序:phase2-pallet-location-schema.sql 之后。 -SET NAMES utf8mb4; -USE baiyue_warehouse; - --- 活动预占必须同时保证“一托一位”;A0 共享只放宽库位,不放宽托盘唯一性。 -SET @schema_name = DATABASE(); -DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing; -DELIMITER $$ -CREATE PROCEDURE add_pallet_reservation_column_if_missing() -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = 'wh_location_reservation' - AND column_name = 'active_pallet_reservation' - ) THEN - ALTER TABLE wh_location_reservation - ADD COLUMN active_pallet_reservation TINYINT - GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) - STORED COMMENT '托盘活动预占唯一标记(包含A0)'; - END IF; -END$$ -DELIMITER ; -CALL add_pallet_reservation_column_if_missing(); -DROP PROCEDURE IF EXISTS add_pallet_reservation_column_if_missing; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = @schema_name - AND table_name = 'wh_location_reservation' - AND index_name = 'uk_pallet_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', - 'SELECT 1'); -PREPARE statement FROM @sql; -EXECUTE statement; -DEALLOCATE PREPARE statement; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema = @schema_name - AND table_name = 'wh_location_reservation' - AND index_name = 'uk_pallet_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', - 'SELECT 1'); -PREPARE statement FROM @sql; -EXECUTE statement; -DEALLOCATE PREPARE statement; - - --- ============================================================================ --- Warehouse A0 共享溢出位与最终并发约束 --- 来源:warehouse/src/main/resources/db/phase2-location-overflow-migration.sql --- ============================================================================ --- 二期库位共享溢出位 A0 增量迁移。 --- 执行顺序:phase2-pallet-location-schema.sql、本文件。 --- 普通库位仍保持一托一位;同一租户仓库只有一个 A0,且 A0 可承载多个活动资源。 -SET NAMES utf8mb4; -USE baiyue_warehouse; - -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; -DELIMITER $$ -CREATE PROCEDURE add_location_overflow_column_if_missing( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_column_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @column_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition - ); - PREPARE column_statement FROM @column_sql; - EXECUTE column_statement; - DEALLOCATE PREPARE column_statement; - END IF; -END$$ -DELIMITER ; - -CALL add_location_overflow_column_if_missing( - 'wh_location', 'shared_overflow', - 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否为系统共享溢出位 A0'' AFTER max_weight_kg' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_reservation', 'shared_overflow', - 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'shared_overflow', - 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' -); - --- 兼容迁移前人工或旧流程已创建的 A0,并将资源快照回填一致。 -UPDATE wh_location - SET shared_overflow = 1, - status = 'AVAILABLE' - WHERE location_code = 'A0' AND deleted = 0; - -UPDATE wh_location_reservation r -JOIN wh_location l - ON l.tenant_id = r.tenant_id AND l.id = r.location_id - SET r.shared_overflow = l.shared_overflow; - -UPDATE wh_location_occupancy o -JOIN wh_location l - ON l.tenant_id = o.tenant_id AND l.id = o.location_id - SET o.shared_overflow = l.shared_overflow; - -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; - --- 先移除依赖旧生成列的唯一索引,再按普通库位/A0 的不同语义重建。 -SET @schema_name = DATABASE(); -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -ALTER TABLE wh_location_reservation - MODIFY COLUMN active_reservation TINYINT - GENERATED ALWAYS AS ( - CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END - ) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL'; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0, - 'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, - 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; -DELIMITER $$ -CREATE PROCEDURE add_location_overflow_column_if_missing( - IN p_table_name VARCHAR(64), - IN p_column_name VARCHAR(64), - IN p_column_definition TEXT -) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = p_table_name - AND column_name = p_column_name - ) THEN - SET @column_sql = CONCAT( - 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), - '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition - ); - PREPARE column_statement FROM @column_sql; - EXECUTE column_statement; - DEALLOCATE PREPARE column_statement; - END IF; -END$$ -DELIMITER ; - -CALL add_location_overflow_column_if_missing( - 'wh_location_reservation', 'active_pallet_reservation', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)''' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'active_location_occupancy', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记''' -); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'active_pallet_occupancy', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)''' -); -DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0, - 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0, - 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - - --- 第三方单条箱规 + 单张图片绑定增量。 -USE baiyue_firstleg; -DROP PROCEDURE IF EXISTS phase2_add_measurement_column; -DELIMITER $$ -CREATE PROCEDURE phase2_add_measurement_column( - IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column - ) THEN - SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); - PREPARE statement FROM @ddl; - EXECUTE statement; - DEALLOCATE PREPARE statement; - END IF; -END$$ -DELIMITER ; -CALL phase2_add_measurement_column('fl_inbound_measurement', 'external_request_hash', - 'CHAR(64) NULL COMMENT ''第三方data原文和图片内容联合请求哈希'' AFTER external_event_no'); -CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'inbound_detail_id', - 'BIGINT UNSIGNED NULL COMMENT ''绑定的订单货物明细ID,平台手工明细可为空'' AFTER measurement_id'); -CALL phase2_add_measurement_column('fl_inbound_measurement_item', 'external_detail_no', - 'VARCHAR(128) NULL COMMENT ''第三方测量明细唯一号,平台手工明细可为空'' AFTER inbound_detail_id'); -DROP PROCEDURE IF EXISTS phase2_add_measurement_column; -SET @has_index = (SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_measurement_item' - AND INDEX_NAME = 'uk_measure_external_detail'); -SET @ddl = IF(@has_index = 0, - 'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no)', - 'SELECT 1'); -PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; - --- 结构核对:只读输出,不写业务数据。 -SELECT 'firstleg 二期核心表数量' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 21, 'PASS', 'FAIL') AS check_result -FROM information_schema.tables -WHERE table_schema = 'baiyue_firstleg' - AND table_name IN ( - 'fl_inbound_order', 'fl_inbound_pickup_snapshot', 'fl_inbound_destination_snapshot', - 'fl_inbound_transport_snapshot', 'fl_inbound_measurement', 'fl_inbound_measurement_item', - 'fl_inbound_weight_result', 'fl_inbound_weight_revision', 'fl_measure_review_record', - 'fl_customer_charge_rule', 'fl_carrier_fba_rate', 'fl_inbound_fee', 'fl_inbound_fee_item', - 'fl_inbound_pallet_allocation', 'fl_inbound_file', 'fl_inbound_hold_record', - 'fl_inbound_internal_note', 'fl_inbound_status_history', 'fl_inbound_outbox', - 'fl_inbound_idempotency', 'fl_third_party_call_log' - ) -UNION ALL -SELECT 'warehouse 二期核心表数量', - COUNT(*), - IF(COUNT(*) = 4, 'PASS', 'FAIL') -FROM information_schema.tables -WHERE table_schema = 'baiyue_warehouse' - AND table_name IN ('wh_pallet', 'wh_location', 'wh_location_reservation', 'wh_location_occupancy') -UNION ALL -SELECT 'file 图片依赖表数量', - COUNT(*), - IF(COUNT(*) = 3, 'PASS', 'FAIL') -FROM information_schema.tables -WHERE table_schema = 'baiyue_file' - AND table_name IN ('file_object', 'file_download_token', 'file_download_log'); - -SELECT 'A0 共享溢出与活动占用关键列' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 6, 'PASS', 'FAIL') AS check_result -FROM information_schema.columns -WHERE (table_schema = 'baiyue_warehouse' AND table_name = 'wh_location' AND column_name = 'shared_overflow') - OR (table_schema = 'baiyue_warehouse' AND table_name = 'wh_location_reservation' - AND column_name IN ('shared_overflow', 'active_reservation', 'active_pallet_reservation')) - OR (table_schema = 'baiyue_warehouse' AND table_name = 'wh_location_occupancy' - AND column_name IN ('shared_overflow', 'active_location_occupancy', 'active_pallet_occupancy')); - -SELECT 'A0 共享溢出与活动占用关键唯一索引' AS check_item, - COUNT(DISTINCT CONCAT(table_schema, '.', table_name, '.', index_name)) AS actual_value, - IF(COUNT(DISTINCT CONCAT(table_schema, '.', table_name, '.', index_name)) = 4, 'PASS', 'FAIL') AS check_result -FROM information_schema.statistics -WHERE table_schema = 'baiyue_warehouse' - AND ( - (table_name = 'wh_location_reservation' AND index_name IN ('uk_location_active_reservation', 'uk_pallet_active_reservation')) - OR (table_name = 'wh_location_occupancy' AND index_name IN ('uk_location_active_occupancy', 'uk_pallet_active_occupancy')) - ); - -SELECT '第三方单条测量绑定关键列' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 3, 'PASS', 'FAIL') AS check_result -FROM information_schema.columns -WHERE table_schema = 'baiyue_firstleg' - AND ((table_name = 'fl_inbound_measurement' AND column_name = 'external_request_hash') - OR (table_name = 'fl_inbound_measurement_item' - AND column_name IN ('inbound_detail_id', 'external_detail_no'))); - -SELECT '第三方单条测量绑定唯一索引' AS check_item, - COUNT(DISTINCT index_name) AS actual_value, - IF(COUNT(DISTINCT index_name) = 1, 'PASS', 'FAIL') AS check_result -FROM information_schema.statistics -WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_measurement_item' - AND index_name = 'uk_measure_external_detail'; - -SELECT '称重结果审计关键列' AS check_item, - COUNT(*) AS actual_value, - IF(COUNT(*) = 2, 'PASS', 'FAIL') AS check_result -FROM information_schema.columns -WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_weight_result' - AND column_name IN ('created_by', 'updated_by'); - -SELECT '二期入库新增表与字段脚本执行完成,请确认以上检查全部为 PASS' AS execution_message; diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 1f1f231fd..f29ea10e0 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,17 +1,36 @@ # 测试环境 SQL 执行说明 -## 本轮一期回归 +## 唯一数据库发布入口 -- 统一入口:`2026-07-31一期回归测试环境增量迁移.sql` +- 固定入口:`头程数据库统一增量-MySQL8.sql`。 +- 该脚本汇总一期、二期当前全部数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 +- 后续数据库变更只允许在该文件末尾增加带日期和业务说明的幂等增量区块,不再创建新的部署入口 SQL。 +- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单。 +- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 +- 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。 +- 完整执行后只有关键表、字段和 28 项二期权限全部存在,末尾才会返回 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 + +后续追加增量必须满足: + +1. 只新增或兼容升级,不清表、不删除业务数据。 +2. 新字段、索引、约束必须先查询 `information_schema`,允许整份脚本重复执行。 +3. 系统权限或字典配置使用唯一键和 `ON DUPLICATE KEY UPDATE` 幂等写入。 +4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。 +5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。 + +## 历史一期回归脚本 + +- 历史文件:`2026-07-31一期回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。 - 适用数据库:MySQL 8.0。 - 执行账号需要对 `baiyue_iam`、`baiyue_warehouse`、`baiyue_order`、`baiyue_firstleg` 具备建表、改表、建索引、创建临时存储过程和数据写入权限。 - 脚本不会创建数据库,不会清理或删除业务数据,也不会写入演示租户、演示用户、演示仓库、演示往来单位或快递公司样本。 - 脚本可重复执行。MySQL 的 DDL 会隐式提交,因此不能把整份跨库脚本视为可整体回滚的事务。 -- 统一入口已内联 IAM 客户档案字段迁移和 `platform:customer:view` 权限迁移;测试环境不要再额外拼接或重复按模块脚本执行。 +- 该历史脚本曾内联 IAM 客户档案字段迁移和 `platform:customer:view` 权限迁移,当前不得再单独执行。 ## IAM 来源与执行顺序 -统一入口中的 IAM 段按以下模块迁移依赖顺序组织,并通过元数据判断或 `ON DUPLICATE KEY UPDATE` 保持可重复执行: +固定入口中的 IAM 段按以下模块迁移依赖顺序组织,并通过元数据判断或 `ON DUPLICATE KEY UPDATE` 保持可重复执行: 1. IAM 基线表必须已存在(`iam.sql`)。 2. 权限父菜单和既有客户列表/审核权限基线(`phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`)。 @@ -19,37 +38,19 @@ 4. `phase1-customer-profile.sql` 中的十个 `iam_tenant` 客户档案字段。 5. `phase1-onboarding.sql` 中的 `PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段。 -统一入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。 +固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。 -测试环境只执行统一入口;模块脚本用于新环境基线构建或单模块升级,不应在统一入口执行后再次串行执行。 +测试环境只执行当前固定入口;历史脚本和模块脚本不应在固定入口执行后再次串行执行。 ## 执行方式 在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 -执行结束后必须看到 `MIGRATION_COMPLETED`,并确认其前面的 IAM 权限、IAM 客户档案十字段、IAM 注册审核字段、资质与成员审计字段、Warehouse 表结构、Order 幂等表和 Firstleg 索引核验均通过。 +执行结束后必须看到 `migration_status = PASS`,并确认消息为“一期与二期统一增量执行完成,关键结构与二期权限校验通过”。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 -## 公共导出中心开发阶段重建 +## 公共导出中心存量升级 -统一迁移脚本中的 `file_export_task` 是一期旧基线,不能自动升级已经存在的表。公共导出中心尚未进入生产、测试环境确认没有需要保留的导出任务时,按下列顺序重建;这一步是公共导出中心上线的额外步骤,不得用于已有真实导出数据的生产环境。 - -先只读确认任务数为 0: - -```sql -SELECT COUNT(*) AS export_task_count FROM baiyue_file.file_export_task; -``` - -只有结果为 0 才允许执行: - -```sql -USE baiyue_file; -DROP TABLE IF EXISTS file_export_outbox; -DROP TABLE IF EXISTS file_export_task_guard; -DROP TABLE IF EXISTS file_export_task; -SOURCE file/src/main/resources/db/file.sql; -``` - -`SOURCE` 的相对路径以 MySQL 客户端启动目录为准;图形客户端可直接完整执行 `file/src/main/resources/db/file.sql`。完成后必须核验: +固定入口已包含 File 当前 6 张表,并通过 `information_schema` 为旧版 `file_export_task` 幂等补齐 8 个调度、重试和租约字段,不再允许通过 `DROP TABLE` 重建。执行完成后可额外核验: ```sql SELECT COUNT(*) = 3 AS export_tables_ready @@ -68,11 +69,19 @@ SELECT COUNT(*) = 8 AS scheduler_columns_ready 'lease_owner', 'lease_until', 'last_heartbeat_at', 'execution_deadline_at'); ``` -任一结果不是 1,禁止启动 File Service。后续一旦表内存在生产数据,必须改用可重复执行的增量迁移,不能继续 DROP 重建。 +任一结果不是 1,禁止启动 File Service;只能继续追加兼容性增量修复,不得删除存量导出任务。 ## 二期第三方测量增量 - 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。 -- 二期统一脚本:`2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` 已内联该增量。 +- 历史二期脚本:`2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` 已冻结,仅供追溯。 +- 当前固定入口 `头程数据库统一增量-MySQL8.sql` 已内联该增量以及订单船司字段、二期 28 项 IAM 权限和最新仓储资源租户字段。 - 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。 -- 本次代码回归不执行真实数据库迁移;测试环境部署 Firstleg 新版本前必须先执行该增量并确认末尾两项检查为 `PASS`。 +- 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。 + +## 海外仓阶梯费用增量 + +- `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。 +- Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。 +- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。 +- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql new file mode 100644 index 000000000..5e3dfc0b8 --- /dev/null +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -0,0 +1,3464 @@ +-- 柏悦头程一期、二期数据库统一增量脚本 +-- 固定发布入口:后续数据库变更只在本文件末尾按日期追加,不再新增部署入口脚本。 +-- 适用:MySQL 8.0+;支持空库初始化和存量库幂等补齐。 +-- 涉及:baiyue_auth、baiyue_iam、baiyue_order、baiyue_warehouse、baiyue_firstleg、baiyue_file、baiyue_audit、baiyue_event_bridge。 +-- 安全边界:不清表、不删业务数据、不创建固定密码账号、不写入演示仓库或演示订单。权限资源属于系统配置,会幂等写入。 +-- MySQL DDL 会隐式提交,跨库脚本不能整体回滚;执行账号必须具有上述数据库的 CREATE/ALTER/INDEX/INSERT/UPDATE 权限。 +-- 使用 MySQL Client、Workbench、DataGrip 或 Navicat 的“运行脚本”完整执行,客户端必须支持 DELIMITER。 +SET NAMES utf8mb4; +SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; +SELECT VERSION() AS mysql_version, 'START' AS unified_migration_status; + +-- ============================================================================ +-- 来源:audit-service\src\main\resources\db\audit.sql +-- ============================================================================ +CREATE DATABASE IF NOT EXISTS baiyue_audit + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_audit; + +CREATE TABLE IF NOT EXISTS sys_audit_log ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID', + event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码', + resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型', + resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID', + description VARCHAR(255) DEFAULT NULL COMMENT '操作说明', + request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪', + trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID', + tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID', + operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID', + user_id BIGINT DEFAULT NULL COMMENT '操作用户ID', + username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照', + app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端', + client_type VARCHAR(32) DEFAULT NULL COMMENT '客户端类型', + http_method VARCHAR(16) DEFAULT NULL COMMENT 'HTTP请求方法', + request_uri VARCHAR(512) DEFAULT NULL COMMENT '请求URI', + client_ip VARCHAR(64) DEFAULT NULL COMMENT '客户端IP', + duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '请求耗时(毫秒)', + success TINYINT NOT NULL DEFAULT 0 COMMENT '操作是否成功', + error_code VARCHAR(64) DEFAULT NULL COMMENT '失败时的错误码', + error_message VARCHAR(512) DEFAULT NULL COMMENT '脱敏后的失败信息', + before_data JSON DEFAULT NULL COMMENT '变更前数据快照(已脱敏)', + after_data JSON DEFAULT NULL COMMENT '变更后数据快照(已脱敏)', + service_name VARCHAR(64) DEFAULT NULL COMMENT '产生日志的服务名', + occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间', + PRIMARY KEY (id), + KEY idx_audit_tenant_time (tenant_id, occurred_at), + KEY idx_audit_event_time (event_type, occurred_at), + KEY idx_audit_user_time (user_id, occurred_at), + KEY idx_audit_request (request_id) +) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; + +-- ============================================================================ +-- 来源:event-bridge-service\src\main\resources\db\event-bridge.sql +-- ============================================================================ +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge; + +CREATE TABLE IF NOT EXISTS bridge_outbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约截止时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at DATETIME(3) NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region, status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='跨区域事件待投递箱'; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id, attempt_no), + KEY idx_bridge_attempt_event (event_id, started_at) +) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录'; + +-- ============================================================================ +-- 来源:auth\src\main\resources\db\auth.sql +-- ============================================================================ +SET NAMES utf8mb4; +CREATE DATABASE IF NOT EXISTS baiyue_auth + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_auth; + +CREATE TABLE IF NOT EXISTS auth_account ( + id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)', + username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名', + password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', + display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称', + account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED', + failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', + locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间', + token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效', + default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID', + operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离', + default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID', + default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_auth_account_username (username), + KEY idx_auth_account_status (account_status, locked_until) +) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态'; + +CREATE TABLE IF NOT EXISTS auth_refresh_token ( + id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)', + token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文', + user_id BIGINT NOT NULL COMMENT '认证账号ID', + tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID', + operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID', + app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端', + expires_at DATETIME(3) NOT NULL COMMENT '到期时间', + revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间', + PRIMARY KEY (id), + UNIQUE KEY uk_auth_refresh_hash (token_hash), + KEY idx_auth_refresh_user (user_id, revoked_at, expires_at), + KEY idx_auth_refresh_expire (expires_at, revoked_at) +) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态'; + +-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。 + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\iam.sql +-- ============================================================================ +SET NAMES utf8mb4; +-- IAM 服务独占数据库:负责租户、成员、双端角色、菜单/页面/按钮/API权限和数据范围。 + +CREATE DATABASE IF NOT EXISTS baiyue_iam + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_iam; + +CREATE TABLE IF NOT EXISTS iam_tenant ( + id BIGINT NOT NULL COMMENT '租户ID(雪花ID)', + tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一租户编码', + tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称', + tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '租户类型', + status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED', + legal_name VARCHAR(128) NULL COMMENT '企业法定名称', + legal_representative VARCHAR(64) NULL COMMENT '法定代表人', + unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', + contact_name VARCHAR(64) NULL COMMENT '企业联系人', + contact_phone VARCHAR(32) NULL COMMENT '联系电话', + contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + service_package VARCHAR(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐', + contact_wechat VARCHAR(64) NULL COMMENT '联系微信', + sales_owner_name VARCHAR(64) NULL COMMENT '销售负责人姓名', + payment_term_days SMALLINT UNSIGNED NULL COMMENT '账期天数', + enterprise_type VARCHAR(64) NULL COMMENT '企业类型', + established_date DATE NULL COMMENT '成立日期', + registered_capital DECIMAL(18,2) NULL COMMENT '注册资本', + registered_capital_currency CHAR(3) NULL COMMENT '注册资本币种 ISO 4217', + registered_address VARCHAR(500) NULL COMMENT '注册地址文本', + internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注', + approved_at DATETIME(3) NULL COMMENT '审核通过时间', + approved_by BIGINT NULL COMMENT '审核人ID', + reject_reason VARCHAR(500) NULL COMMENT '驳回原因', + created_by BIGINT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_iam_tenant_code (tenant_code), + UNIQUE KEY uk_iam_tenant_credit (unified_social_credit_code), + KEY idx_iam_tenant_status (status, created_at) +) ENGINE=InnoDB COMMENT='企业租户主表:作为SaaS数据隔离边界,保存商户企业资料、审核结果和启停状态'; + +CREATE TABLE IF NOT EXISTS iam_tenant_review ( + id BIGINT NOT NULL COMMENT '审核记录ID', + tenant_id BIGINT NOT NULL COMMENT '被审核租户ID', + review_type VARCHAR(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态', + reviewer_id BIGINT NULL COMMENT '审核人ID', + review_comment VARCHAR(500) NULL COMMENT '审核意见', + reviewed_at DATETIME(3) NULL COMMENT '审核完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '进入审核队列时间', + PRIMARY KEY (id), + KEY idx_review_queue (status, created_at), + KEY idx_review_tenant (tenant_id) +) ENGINE=InnoDB COMMENT='租户审核记录表:保存企业注册、资料变更等审核过程和审核意见'; + +CREATE TABLE IF NOT EXISTS iam_tenant_address ( + id BIGINT NOT NULL COMMENT '租户地址ID', + tenant_id BIGINT NOT NULL COMMENT '所属租户ID', + address_type VARCHAR(20) NOT NULL COMMENT '地址类型', + country_code CHAR(2) NOT NULL DEFAULT 'CN' COMMENT '国家二字码', + province VARCHAR(64) NULL COMMENT '省/州', + city VARCHAR(64) NULL COMMENT '城市', + district VARCHAR(64) NULL COMMENT '区县', + detail VARCHAR(255) NOT NULL COMMENT '详细地址', + postal_code VARCHAR(16) NULL COMMENT '邮政编码', + is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (id), + KEY idx_address_tenant (tenant_id, address_type) +) ENGINE=InnoDB COMMENT='租户地址表:保存企业注册地址、办公地址等租户级资料'; + +CREATE TABLE IF NOT EXISTS iam_tenant_qualification ( + id BIGINT NOT NULL COMMENT '资质记录ID', + tenant_id BIGINT NOT NULL COMMENT '所属租户ID', + qualification_type VARCHAR(32) NOT NULL COMMENT '资质类型', + certificate_no VARCHAR(128) NULL COMMENT '证书编号', + document_url VARCHAR(500) NULL COMMENT '资质文件存储地址', + valid_from DATE NULL COMMENT '有效期开始日期', + valid_to DATE NULL COMMENT '有效期结束日期', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态', + review_comment VARCHAR(500) NULL COMMENT '审核意见', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (id), + KEY idx_qualification_tenant (tenant_id, status) +) ENGINE=InnoDB COMMENT='租户资质表:保存企业证照、有效期、文件地址和审核状态'; + +CREATE TABLE IF NOT EXISTS iam_user ( + id BIGINT NOT NULL COMMENT 'IAM用户资料ID', + auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID', + user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型', + display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称', + phone VARCHAR(32) NULL COMMENT '手机号', + email VARCHAR(128) NULL COMMENT '邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '用户资料状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_iam_user_auth (auth_user_id), + KEY idx_iam_user_status (status) +) ENGINE=InnoDB COMMENT='IAM用户资料表:连接统一认证账号与业务身份资料,不保存密码'; + +CREATE TABLE IF NOT EXISTS iam_membership ( + id BIGINT NOT NULL COMMENT '租户成员关系ID', + tenant_id BIGINT NOT NULL COMMENT '所属租户ID', + user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', + member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型', + member_no VARCHAR(64) NULL COMMENT '租户内部成员编号', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态', + joined_at DATETIME(3) NULL COMMENT '加入租户时间', + left_at DATETIME(3) NULL COMMENT '离开租户时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_membership_tenant_user (tenant_id, user_id), + UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no), + KEY idx_membership_user (user_id, status), + KEY idx_membership_tenant (tenant_id, status) +) ENGINE=InnoDB COMMENT='租户成员关系表:表示一个用户在某个企业租户中的成员身份,是分配商户端角色的主体'; + +CREATE TABLE IF NOT EXISTS iam_role ( + id BIGINT NOT NULL COMMENT '角色ID', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色所属租户;0表示平台预置角色', + app_code VARCHAR(20) NOT NULL COMMENT '应用端:MERCHANT或PLATFORM', + role_code VARCHAR(64) NOT NULL COMMENT '作用域内唯一角色编码', + role_name VARCHAR(128) NOT NULL COMMENT '角色名称', + role_type VARCHAR(20) NOT NULL DEFAULT 'CUSTOM' COMMENT '角色类型:SYSTEM或CUSTOM', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态', + description VARCHAR(255) NULL COMMENT '角色用途说明', + created_by BIGINT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT NULL COMMENT '更新人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_role_scope_code (tenant_id, app_code, role_code), + KEY idx_role_tenant (tenant_id, app_code, status) +) ENGINE=InnoDB COMMENT='角色表:按租户和应用端隔离商户端、平台后台两套角色体系'; + +CREATE TABLE IF NOT EXISTS iam_membership_role ( + membership_id BIGINT NOT NULL COMMENT '租户成员关系ID', + role_id BIGINT NOT NULL COMMENT '角色ID', + tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于租户隔离校验和查询', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', + PRIMARY KEY (membership_id, role_id), + KEY idx_mr_tenant (tenant_id) +) ENGINE=InnoDB COMMENT='成员角色关联表:将租户成员分配到对应应用端的一个或多个角色'; + +CREATE TABLE IF NOT EXISTS iam_permission ( + id BIGINT NOT NULL COMMENT '权限资源ID', + tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '权限所属租户;0表示平台预置权限', + app_code VARCHAR(20) NOT NULL COMMENT '所属应用端:MERCHANT或PLATFORM', + permission_type VARCHAR(20) NOT NULL COMMENT '权限类型:MENU、PAGE、BUTTON或API', + parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父权限ID,用于构造菜单和页面树', + permission_code VARCHAR(128) NOT NULL COMMENT '鉴权使用的唯一权限码', + permission_name VARCHAR(128) NOT NULL COMMENT '权限名称', + route_path VARCHAR(255) NULL COMMENT '前端路由或API路径', + http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法', + sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态', + built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置权限不可通过管理接口修改', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (id), + UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code), + KEY idx_permission_parent (parent_id), + KEY idx_permission_api (http_method, route_path) +) ENGINE=InnoDB COMMENT='统一权限资源表:支持商户端和平台后台两套菜单、页面、按钮及API权限,权限最细可控制到按钮'; + +CREATE TABLE IF NOT EXISTS iam_role_permission ( + role_id BIGINT NOT NULL COMMENT '角色ID', + permission_id BIGINT NOT NULL COMMENT '权限资源ID', + tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于隔离校验和查询', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间', + PRIMARY KEY (role_id, permission_id), + KEY idx_rp_tenant (tenant_id) +) ENGINE=InnoDB COMMENT='角色权限关联表:定义角色可访问的菜单、页面、按钮和API资源'; + +CREATE TABLE IF NOT EXISTS iam_data_scope ( + id BIGINT NOT NULL COMMENT '数据范围规则ID', + role_id BIGINT NOT NULL COMMENT '角色ID', + tenant_id BIGINT NOT NULL COMMENT '规则所属租户ID', + scope_type VARCHAR(20) NOT NULL DEFAULT 'SELF' COMMENT '范围类型:SELF、DEPARTMENT、TENANT或CUSTOM', + scope_expression JSON NULL COMMENT '自定义数据范围表达式', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_scope_role (role_id) +) ENGINE=InnoDB COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的租户内数据集合'; + +-- 存量数据库重复执行初始化脚本时,不重复创建审核状态索引。 +SET @review_status_index_sql = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'iam_tenant_review' + AND index_name = 'idx_tenant_review_status'), + 'SELECT 1', + 'CREATE INDEX idx_tenant_review_status ON iam_tenant_review (tenant_id, status)' +); +PREPARE review_status_index_stmt FROM @review_status_index_sql; +EXECUTE review_status_index_stmt; +DEALLOCATE PREPARE review_status_index_stmt; + +-- ============================================================================ +-- 来源:order\src\main\resources\db\order.sql +-- ============================================================================ +SET NAMES utf8mb4; +-- 订单服务独占数据库。其他服务只能通过 OpenAPI 或领域事件获取订单数据。 + +CREATE DATABASE IF NOT EXISTS baiyue_order + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_order; + +CREATE TABLE IF NOT EXISTS ord_order ( + id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号', + customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', + customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态', + transport_mode VARCHAR(16) NOT NULL COMMENT '运输方式', + trade_term VARCHAR(16) NULL COMMENT '贸易条款', + destination_country_code CHAR(2) NOT NULL COMMENT '目的国家二字码', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID', + destination_warehouse_name VARCHAR(128) NOT NULL COMMENT '目的仓名称快照', + expected_ship_date DATE NULL COMMENT '预计发运日期', + customer_remark VARCHAR(1000) NULL COMMENT '客户可见备注', + pickup_method VARCHAR(24) NULL COMMENT '取货方式', + receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID', + receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照', + receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照', + receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照', + channel_code VARCHAR(64) NULL COMMENT '渠道编码', + channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', + carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照', + carrier_name VARCHAR(128) NULL COMMENT '船司名称快照', + tax_mode VARCHAR(24) NULL COMMENT '买单或退税', + container_mode VARCHAR(24) NULL COMMENT '整柜或散货', + customs_inspection TINYINT(1) NULL COMMENT '是否商检', + destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE', + amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码', + shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID', + reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID', + express_company VARCHAR(64) NULL COMMENT '履约快递公司名称快照', + express_tracking_no VARCHAR(128) NULL COMMENT '履约快递单号', + submitted_at DATETIME(3) NULL COMMENT '提交时间', + completed_at DATETIME(3) 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_order_tenant_no (tenant_id, order_no), + KEY idx_order_tenant_status_time (tenant_id, status, created_at), + KEY idx_order_tenant_customer (tenant_id, customer_id), + KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) +) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; + +CREATE TABLE IF NOT EXISTS ord_order_party ( + id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID', + party_type VARCHAR(16) NOT NULL COMMENT '参与方类型:SHIPPER或CONSIGNEE', + company_name VARCHAR(128) NOT NULL COMMENT '公司名称快照', + contact_name VARCHAR(64) NOT NULL COMMENT '联系人快照', + phone VARCHAR(32) NOT NULL COMMENT '联系电话快照', + email VARCHAR(128) NULL COMMENT '联系邮箱快照', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', + province VARCHAR(64) NULL COMMENT '省/州', + city VARCHAR(64) NOT NULL COMMENT '城市', + district VARCHAR(64) NULL COMMENT '区县', + address_line VARCHAR(255) NOT NULL COMMENT '详细地址', + postal_code VARCHAR(20) 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_party_order_type (tenant_id, order_id, party_type), + CONSTRAINT fk_party_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB COMMENT='订单收发货方快照表:固化下单时发货人和收货人信息,避免主数据变化影响历史订单'; + +CREATE TABLE IF NOT EXISTS ord_order_cargo ( + id BIGINT UNSIGNED NOT NULL COMMENT '货物明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID', + item_no INT NOT NULL COMMENT '订单内明细序号', + cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称', + hs_code VARCHAR(20) NULL COMMENT '海关商品编码', + package_type VARCHAR(32) NOT NULL COMMENT '包装类型', + package_count INT UNSIGNED NOT NULL COMMENT '包装件数', + gross_weight_kg DECIMAL(18,3) NOT NULL COMMENT '毛重(千克)', + volume_cbm DECIMAL(18,4) NOT NULL COMMENT '体积(立方米)', + length_cm DECIMAL(12,2) NULL COMMENT '单件长度(厘米)', + width_cm DECIMAL(12,2) NULL COMMENT '单件宽度(厘米)', + height_cm DECIMAL(12,2) NULL COMMENT '单件高度(厘米)', + declared_value DECIMAL(18,2) NULL COMMENT '申报价值', + currency CHAR(3) NULL COMMENT '币种三字码', + dangerous_goods TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否危险品', + cargo_remark VARCHAR(500) NULL COMMENT '货物备注', + sku_no VARCHAR(64) NULL COMMENT 'SKU', + upc_no VARCHAR(64) NULL COMMENT 'UPC', + cargo_name_en VARCHAR(128) NULL COMMENT '英文品名', + customs_hs_code VARCHAR(20) NULL COMMENT '目的国清关HS CODE', + units_per_package INT UNSIGNED NULL COMMENT '每箱件数', + total_quantity INT UNSIGNED NULL COMMENT '总件数', + net_weight_kg DECIMAL(18,3) NULL COMMENT '净重kg', + unit_declared_value DECIMAL(18,2) NULL COMMENT '单件申报价值', + material VARCHAR(128) NULL COMMENT '材质', + product_usage VARCHAR(128) NULL COMMENT '用途', + model VARCHAR(64) NULL COMMENT '型号', + brand VARCHAR(64) 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_cargo_item (tenant_id, order_id, item_no), + KEY idx_cargo_order (tenant_id, order_id), + CONSTRAINT fk_cargo_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB COMMENT='订单货物明细表:记录件数、重量、体积、申报价值和危险品属性'; + +CREATE TABLE IF NOT EXISTS ord_order_internal_note ( + id BIGINT UNSIGNED NOT NULL COMMENT '内部备注ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID', + content VARCHAR(2000) 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), + KEY idx_note_order_time (tenant_id, order_id, created_at), + CONSTRAINT fk_note_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB COMMENT='订单内部备注表:保存平台运营或履约人员的内部协作记录,不向商户客户展示'; + +CREATE TABLE IF NOT EXISTS ord_order_status_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '状态历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '所属订单ID', + from_status VARCHAR(24) NULL COMMENT '变更前状态,首次创建可为空', + to_status VARCHAR(24) NOT NULL COMMENT '变更后状态', + action VARCHAR(24) NOT NULL COMMENT '触发状态变化的业务动作', + reason VARCHAR(500) NULL COMMENT '变更原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态变化时间', + PRIMARY KEY (id), + KEY idx_history_order_time (tenant_id, order_id, occurred_at), + CONSTRAINT fk_history_order FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB COMMENT='订单状态不可变历史表:按时间记录每次状态迁移、动作、原因和操作人'; + +CREATE TABLE IF NOT EXISTS ord_outbox_event ( + id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID,同时作为消费幂等键', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID', + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型,例如 ORDER', + aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '发生变更的聚合根ID,例如订单ID', + event_type VARCHAR(64) NOT NULL COMMENT '领域事件类型,例如 ORDER_SUBMITTED', + payload_json JSON NOT NULL COMMENT '供下游消费的事件负载快照', + publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态:PENDING、PUBLISHED或FAILED', + retry_count INT NOT NULL DEFAULT 0 COMMENT '投递失败后的累计重试次数', + next_retry_at DATETIME(3) NULL COMMENT '允许再次投递的最早时间', + published_at DATETIME(3) NULL COMMENT '成功发布到消息中间件的时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件在本地事务中的创建时间', + PRIMARY KEY (id), + KEY idx_outbox_publish (publish_status, next_retry_at, created_at) +) ENGINE=InnoDB COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志'; + +CREATE TABLE IF NOT EXISTS ord_idempotency_record ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', + operation VARCHAR(32) NOT NULL COMMENT '幂等操作类型', + resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源ID,创建草稿时为0', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256摘要', + status VARCHAR(16) NOT NULL COMMENT '处理状态:PROCESSING或COMPLETED', + result_order_id BIGINT UNSIGNED NULL COMMENT '成功结果订单ID', + completed_at DATETIME(3) NULL COMMENT '处理完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), + KEY idx_order_idempotency_created (created_at) +) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录:唯一键保证同租户同操作者并发请求只执行一次'; + +-- ============================================================================ +-- 来源:warehouse\src\main\resources\db\warehouse.sql +-- ============================================================================ +SET NAMES utf8mb4; +CREATE DATABASE IF NOT EXISTS baiyue_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS', + country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', + contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + remark VARCHAR(500) DEFAULT NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_wh_tenant_code (tenant_id, warehouse_code, deleted), + KEY idx_wh_tenant_status (tenant_id, status, deleted), + KEY idx_wh_tenant_type_status (tenant_id, warehouse_type, status, deleted), + KEY idx_wh_country (tenant_id, country_code, deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', + state_province VARCHAR(100) DEFAULT NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', + district VARCHAR(100) DEFAULT NULL COMMENT '区县', + postal_code VARCHAR(20) DEFAULT NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + address_line2 VARCHAR(255) DEFAULT NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) DEFAULT NULL COMMENT '纬度', + longitude DECIMAL(10,7) DEFAULT NULL COMMENT '经度', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_address_type (tenant_id, warehouse_id, address_type, deleted), + KEY idx_address_warehouse (tenant_id, warehouse_id, deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME DEFAULT NULL COMMENT '当日作业截单时间', + description VARCHAR(500) DEFAULT NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_capability (tenant_id, warehouse_id, capability_code, deleted), + KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 tenant_id 与 warehouse_id 共同校验。 + +-- ============================================================================ +-- 来源:warehouse\src\main\resources\db\warehouse-partner.sql +-- ============================================================================ +SET NAMES utf8mb4; +USE baiyue_warehouse; +CREATE TABLE IF NOT EXISTS wh_partner ( + id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, + data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', + partner_code VARCHAR(50) NOT NULL, partner_name VARCHAR(128) NOT NULL, + partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH', + contact_name VARCHAR(64) NOT NULL, contact_phone VARCHAR(32) NOT NULL, + contact_email VARCHAR(128) NULL, country_code CHAR(2) NOT NULL, + province VARCHAR(64) NULL, city VARCHAR(64) NOT NULL, district VARCHAR(64) NULL, + address_line VARCHAR(255) NOT NULL, postal_code VARCHAR(20) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', remark VARCHAR(500) NULL, + created_by BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, + PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), + KEY idx_wh_partner_tenant_status(tenant_id,status,deleted) +) ENGINE=InnoDB COMMENT='仓储域内的租户往来单位配置'; +CREATE TABLE IF NOT EXISTS wh_partner_type ( + partner_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, + data_scope_id BIGINT UNSIGNED NOT NULL, partner_type VARCHAR(32) NOT NULL, + PRIMARY KEY(partner_id,partner_type), + KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id) +) ENGINE=InnoDB COMMENT='往来单位稳定业务类型'; +INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status) +VALUES(191000000000000001,10001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE') +ON DUPLICATE KEY UPDATE partner_name=VALUES(partner_name),status='ACTIVE'; +INSERT INTO wh_partner_type(partner_id,tenant_id,data_scope_id,partner_type) +VALUES +(191000000000000001,10001,10001,'CUSTOMER'), +(191000000000000001,10001,10001,'SHIPPER') +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id); + +-- ============================================================================ +-- 来源:warehouse\src\main\resources\db\warehouse-express-company.sql +-- ============================================================================ +SET NAMES utf8mb4; +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_express_company ( + id BIGINT UNSIGNED NOT NULL COMMENT '快递公司记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所属租户ID', + company_code VARCHAR(50) NOT NULL COMMENT '租户内唯一快递公司编码', + company_name VARCHAR(128) NOT NULL COMMENT '快递公司名称', + service_phone VARCHAR(32) DEFAULT NULL COMMENT '客服电话', + website VARCHAR(255) DEFAULT NULL COMMENT '官方网站', + tracking_url_template VARCHAR(500) DEFAULT NULL COMMENT '轨迹查询地址模板,可包含{trackingNo}', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '快递公司状态', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_wh_express_tenant_code (tenant_id, company_code, deleted), + UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted), + KEY idx_wh_express_tenant_status (tenant_id, status, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据'; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\firstleg.sql +-- ============================================================================ +-- 头程履约服务独占数据库。 +CREATE DATABASE IF NOT EXISTS baiyue_firstleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_fulfillment_task ( + id BIGINT UNSIGNED NOT NULL COMMENT '履约任务ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务中的订单逻辑ID,不建立跨库外键', + order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', + order_snapshot_json JSON NOT NULL COMMENT '创建履约任务时所需的订单业务快照', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约任务当前状态', + assignee_id BIGINT UNSIGNED NULL COMMENT '当前负责人ID', + assignee_name VARCHAR(64) NULL COMMENT '当前负责人名称快照', + assigned_at DATETIME(3) NULL COMMENT '最近指派时间', + started_at DATETIME(3) NULL COMMENT '开始履约时间', + completed_at DATETIME(3) 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_task_tenant_order (tenant_id, order_id), + KEY idx_task_tenant_created (tenant_id, created_at), + KEY idx_task_assignee_status (tenant_id, assignee_id, status, created_at) +) ENGINE=InnoDB COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'; + +CREATE TABLE IF NOT EXISTS fl_assignment_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '指派历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + task_id BIGINT UNSIGNED NOT NULL COMMENT '所属履约任务ID', + previous_assignee_id BIGINT UNSIGNED NULL COMMENT '原负责人ID', + assignee_id BIGINT UNSIGNED NOT NULL COMMENT '新负责人ID', + assignee_name VARCHAR(64) NOT NULL COMMENT '新负责人名称快照', + reason VARCHAR(500) NULL COMMENT '指派或改派原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行指派的操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '指派发生时间', + PRIMARY KEY (id), + KEY idx_assignment_task_time (tenant_id, task_id, created_at), + CONSTRAINT fk_assignment_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) +) ENGINE=InnoDB COMMENT='头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'; + +CREATE TABLE IF NOT EXISTS fl_consumed_event ( + event_id BIGINT UNSIGNED NOT NULL COMMENT '已消费领域事件ID,与生产端Outbox事件ID一致', + event_type VARCHAR(64) NOT NULL COMMENT '已消费事件类型', + consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功消费时间', + PRIMARY KEY (event_id) +) ENGINE=InnoDB COMMENT='头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'; + +-- ============================================================================ +-- 来源:file\src\main\resources\db\file.sql +-- ============================================================================ +-- 文件中心独占数据库,其他服务只保存文件逻辑ID。 +CREATE DATABASE IF NOT EXISTS baiyue_file + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_file; + +CREATE TABLE IF NOT EXISTS file_object ( + id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + business_type VARCHAR(32) NULL COMMENT '关联业务类型', + business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', + original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', + object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', + storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方', + bucket_name VARCHAR(128) NOT NULL COMMENT '存储桶名称', + content_type VARCHAR(128) NOT NULL COMMENT '文件内容类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件字节数', + sha256 CHAR(64) NULL COMMENT '文件SHA-256摘要', + status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' 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_file_tenant_object (tenant_id, object_key), + KEY idx_file_tenant_business (tenant_id, business_type, business_id), + KEY idx_file_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB COMMENT='文件元数据表'; + +CREATE TABLE IF NOT EXISTS file_download_token ( + id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', + token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要', + expires_at DATETIME(3) NOT NULL COMMENT '过期时间', + max_downloads INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '最大下载次数', + download_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已下载次数', + revoked_at DATETIME(3) NULL COMMENT '撤销时间', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_download_token_hash (token_hash), + KEY idx_download_file (tenant_id, file_id), + KEY idx_download_expiry (expires_at) +) ENGINE=InnoDB COMMENT='临时下载授权令牌表'; + +CREATE TABLE IF NOT EXISTS file_download_log ( + id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', + user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID', + request_id VARCHAR(64) NULL COMMENT '请求ID', + client_ip VARCHAR(64) NULL COMMENT '客户端IP', + result VARCHAR(16) NOT NULL COMMENT '下载结果', + failure_reason VARCHAR(500) NULL COMMENT '失败原因', + downloaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下载时间', + PRIMARY KEY (id), + KEY idx_download_log_file (tenant_id, file_id, downloaded_at), + KEY idx_download_log_user (tenant_id, user_id, downloaded_at) +) ENGINE=InnoDB COMMENT='文件下载审计日志表'; + +CREATE TABLE IF NOT EXISTS file_export_task ( + id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0', + app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT', + task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号', + export_type VARCHAR(64) NOT NULL COMMENT '导出类型', + format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式', + task_name VARCHAR(128) NOT NULL COMMENT '任务名称', + parameters_json JSON NOT NULL COMMENT '筛选条件快照', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCEEDED/FAILED/EXPIRED', + total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数', + processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数', + file_name VARCHAR(255) NULL COMMENT '下载文件名', + object_key VARCHAR(512) NULL COMMENT '存储对象键', + content_type VARCHAR(128) NULL COMMENT '文件类型', + size_bytes BIGINT UNSIGNED NULL COMMENT '文件字节数', + failure_message VARCHAR(1000) NULL COMMENT '失败原因', + dispatch_version INT NOT NULL DEFAULT 1 COMMENT '派发版本,仅执行重试时递增', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已执行尝试次数', + max_attempts INT NOT NULL DEFAULT 3 COMMENT '自动重试上限', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许执行的最早时间', + lease_owner VARCHAR(128) NULL COMMENT '当前执行实例标识', + lease_until DATETIME(3) NULL COMMENT '执行租约截止时间', + last_heartbeat_at DATETIME(3) NULL COMMENT '最后心跳时间', + execution_deadline_at DATETIME(3) NULL COMMENT '本次执行截止时间', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请用户ID', + requested_username VARCHAR(128) NULL COMMENT '申请账号快照', + requested_at DATETIME(3) NOT NULL COMMENT '申请时间', + started_at DATETIME(3) NULL COMMENT '开始时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + expires_at DATETIME(3) NULL COMMENT '文件过期时间', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED 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_file_export_task_no (task_no), + KEY idx_file_export_owner (tenant_id, app_code, requested_by, requested_at), + KEY idx_file_export_status (status, requested_at), + KEY idx_file_export_dispatch (status, next_retry_at, id), + KEY idx_file_export_lease (status, lease_until), + KEY idx_file_export_expiry (status, expires_at) +) ENGINE=InnoDB COMMENT='公共异步导出任务表'; + +CREATE TABLE IF NOT EXISTS file_export_task_guard ( + guard_key VARCHAR(64) NOT NULL COMMENT '任务创建串行化锁键', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (guard_key) +) ENGINE=InnoDB COMMENT='导出任务创建并发保护表:低频创建入口使用单行锁保证重复提交及活动上限'; + +INSERT IGNORE INTO file_export_task_guard (guard_key) VALUES ('GLOBAL_CREATE'); + +CREATE TABLE IF NOT EXISTS file_export_outbox ( + id BIGINT UNSIGNED NOT NULL COMMENT 'Outbox记录ID', + event_id VARCHAR(64) NOT NULL COMMENT '投递意图唯一标识', + task_id BIGINT UNSIGNED NOT NULL COMMENT '关联导出任务ID', + dispatch_version INT NOT NULL COMMENT '任务派发版本', + topic VARCHAR(128) NOT NULL COMMENT '目标Topic', + tag VARCHAR(64) NOT NULL COMMENT '消息Tag', + message_key VARCHAR(64) NOT NULL COMMENT '消息Key', + payload_json JSON NOT NULL COMMENT '命令消息体', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CLAIMED/SENT/QUARANTINED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数', + next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间', + lease_owner VARCHAR(128) NULL COMMENT '领取实例标识', + lease_until DATETIME(3) NULL COMMENT '领取租约截止时间', + sent_at DATETIME(3) NULL COMMENT '发送成功时间', + last_error VARCHAR(1000) NULL COMMENT '最后发送错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_file_export_outbox_event (event_id), + UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version), + KEY idx_file_export_outbox_publish (status, next_retry_at, id), + KEY idx_file_export_outbox_task (task_id, dispatch_version, status, sent_at) +) ENGINE=InnoDB COMMENT='导出命令事务Outbox表'; + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase1-admin-rbac.sql +-- ============================================================================ +-- Phase-one administrator identities and RBAC seed data. +-- Scope is intentionally limited to first-level menus, second-level menus and buttons. +-- PAGE and API permission resources are not part of phase one. +USE baiyue_iam; +SET NAMES utf8mb4; + +-- Keep the role table aligned with the shared BaseEntity audit fields. +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_role' AND column_name = 'created_by'), + 'SELECT 1', + 'ALTER TABLE iam_role ADD COLUMN created_by BIGINT NULL AFTER description' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_role' AND column_name = 'updated_by'), + 'SELECT 1', + 'ALTER TABLE iam_role ADD COLUMN updated_by BIGINT NULL AFTER created_at' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO iam_tenant ( + id, tenant_code, tenant_name, tenant_type, status, legal_name, + unified_social_credit_code, contact_name, contact_phone, contact_email, + approved_at, approved_by, created_by, deleted, version +) VALUES + (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', + NULL, '平台管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), + (10001, 'PHASE1_DEMO', '一期演示企业', 'MERCHANT', 'ACTIVE', '一期演示企业有限公司', + '91310000PHASE10001', '企业管理员', '13800000000', 'merchant@example.com', + NOW(3), 1000001, 1000001, 0, 0) +ON DUPLICATE KEY UPDATE + tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), + status = 'ACTIVE', legal_name = VALUES(legal_name), + contact_name = VALUES(contact_name), contact_phone = VALUES(contact_phone), + contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), + approved_by = VALUES(approved_by), deleted = 0; + +INSERT INTO iam_user ( + id, auth_user_id, user_type, display_name, phone, email, status, deleted, version +) VALUES + (2000001, 1000001, 'INTERNAL', '平台管理员', NULL, NULL, 'ACTIVE', 0, 0), + (2000002, 1000002, 'ENTERPRISE', '一期演示企业管理员', '13800000000', + 'merchant@example.com', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), + status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_membership ( + id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version +) VALUES + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), + (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) +ON DUPLICATE KEY UPDATE + member_type = VALUES(member_type), member_no = VALUES(member_no), + status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; + +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, description, deleted, version +) VALUES + (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), + (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), + (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), + (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) +ON DUPLICATE KEY UPDATE + role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', + description = VALUES(description), deleted = 0; + +-- Platform: level-one menus. +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5100000, 0, 'PLATFORM', 'MENU', 0, 'platform:customer', '客户管理', '/customers', NULL, 10, 'ACTIVE', 0), + (5101000, 0, 'PLATFORM', 'MENU', 0, 'platform:system', '系统管理', '/system', NULL, 20, 'ACTIVE', 0), + (5102000, 0, 'PLATFORM', 'MENU', 0, 'platform:order', '头程订单', '/first-leg-orders', NULL, 30, 'ACTIVE', 0), + (5103000, 0, 'PLATFORM', 'MENU', 0, 'platform:master-data', '基础资料', '/master-data', NULL, 40, 'ACTIVE', 0), + (5104000, 0, 'PLATFORM', 'MENU', 0, 'platform:audit', '审计管理', '/audit', NULL, 50, 'ACTIVE', 0), +-- Platform: level-two menus. + (5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 0), + (5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 0), + (5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0), + (5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0), + (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 0), + (5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), + (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0), + (5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0), +-- Platform: buttons. + (5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0), + (5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0), + (5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0), + (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 0), + (5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0), + (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', NULL, NULL, 20, 'ACTIVE', 0), + (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', NULL, NULL, 30, 'ACTIVE', 0), + (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', NULL, NULL, 40, 'ACTIVE', 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配用户角色', NULL, NULL, 50, 'ACTIVE', 0), + (5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 0), + (5102011, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:approve', '订单审核通过', NULL, NULL, 10, 'ACTIVE', 0), + (5102012, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:reject', '订单驳回', NULL, NULL, 20, 'ACTIVE', 0), + (5102013, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:accept', '订单受理', NULL, NULL, 30, 'ACTIVE', 0), + (5102014, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:process', '开始处理', NULL, NULL, 40, 'ACTIVE', 0), + (5102015, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:complete', '完成订单', NULL, NULL, 50, 'ACTIVE', 0), + (5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0), + (5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0), + (5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0), + (5102021, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:task:view', '查看履约任务', NULL, NULL, 10, 'ACTIVE', 0), + (5102022, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:history', '查看分配历史', NULL, NULL, 20, 'ACTIVE', 0), + (5102023, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:assign', '分配履约任务', NULL, NULL, 30, 'ACTIVE', 0), + (5103011, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:create', '新增仓库', NULL, NULL, 10, 'ACTIVE', 0), + (5103012, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:edit', '编辑仓库', NULL, NULL, 20, 'ACTIVE', 0), + (5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0), + (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 0), + (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 0), + (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启用停用快递公司', NULL, NULL, 30, 'ACTIVE', 0), + (5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', NULL, NULL, 40, 'ACTIVE', 0), + (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 0), + (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0), + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0), + (5103034, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:import', '批量导入往来单位', NULL, NULL, 40, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +-- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。 +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0), + (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0), + (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id IN (5102019, 5102024, 5102025) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102019, 5102024, 5102025) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- Merchant: level-one menus, level-two menus and buttons. +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), + (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES + (3000001, 4000001, 0), + (3000002, 4000101, 10001) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- Full administrator roles receive all phase-one permissions in their application scope. +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000101, id, 10001 FROM iam_permission +WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- Limited platform and merchant operational roles. +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000002, id, 0 FROM iam_permission +WHERE permission_code IN ( + 'platform:customer', 'platform:customer:list', 'platform:customer:review', + 'platform:customer:approve', 'platform:customer:reject', 'platform:customer:export', + 'platform:master-data', 'platform:download:list' +) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000102, id, 10001 FROM iam_permission +WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES + (6000001, 4000001, 0, 'TENANT', NULL), + (6000002, 4000002, 0, 'TENANT', NULL), + (6000003, 4000003, 0, 'TENANT', NULL), + (6000101, 4000101, 10001, 'TENANT', NULL), + (6000102, 4000102, 10001, 'TENANT', NULL) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase1-onboarding.sql +-- ============================================================================ +-- Phase-one merchant onboarding state migration. +USE baiyue_iam; +SET NAMES utf8mb4; + +ALTER TABLE iam_tenant MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' + COMMENT 'PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED or DISABLED'; +DROP PROCEDURE IF EXISTS phase1_iam_add_column; +DELIMITER $$ +CREATE PROCEDURE phase1_iam_add_column( + IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table_name AND COLUMN_NAME = p_column_name + ) THEN + SET @phase1_iam_sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE phase1_iam_stmt FROM @phase1_iam_sql; + EXECUTE phase1_iam_stmt; + DEALLOCATE PREPARE phase1_iam_stmt; + END IF; +END$$ +DELIMITER ; +CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT 'Approval time'"); +CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'"); +CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'"); +CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name"); +CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'"); +CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'"); +CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'"); +DROP PROCEDURE phase1_iam_add_column; + +-- A global merchant owner template is assigned to newly registered enterprises. +INSERT INTO iam_role ( + id, tenant_id, app_code, role_code, role_name, role_type, status, + description, created_by, updated_by, deleted, version +) VALUES ( + 4000100, 0, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', + '一期企业全部菜单和操作按钮模板', 1000001, 1000001, 0, 0 +) +ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; + +-- Clone the一期 merchant menu/button template into the global scope. +INSERT INTO iam_permission ( + id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, deleted +) +SELECT id + 100000, 0, app_code, permission_type, + CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, + permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 +FROM iam_permission +WHERE tenant_id = 10001 AND app_code = 'MERCHANT' + AND permission_type IN ('MENU', 'BUTTON') +ON DUPLICATE KEY UPDATE + permission_name=VALUES(permission_name), route_path=VALUES(route_path), + parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT owner_role.id, permission.id, 0 +FROM iam_role owner_role +JOIN iam_permission permission + ON permission.app_code='MERCHANT' AND permission.tenant_id=0 + AND permission.permission_type IN ('MENU','BUTTON') + AND permission.status='ACTIVE' AND permission.deleted=0 +WHERE owner_role.tenant_id=0 AND owner_role.app_code='MERCHANT' + AND owner_role.role_code='MERCHANT_OWNER' + AND owner_role.status='ACTIVE' AND owner_role.deleted=0 +ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); + +-- 不再写入伪造的资质 URL。资质必须先由 File 服务保存真实图片,再由 IAM 记录文件地址和审核结果。 +-- 已部署环境中的 /phase1-seed/ 历史记录按 docs/企业资质文件迁移说明.md 处理,本脚本不得将其改成不存在的 UUID。 + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase1-member-permissions.sql +-- ============================================================================ +SET NAMES utf8mb4; +-- 企业成员管理按钮权限增量;部署后应将权限按需授予企业管理员角色。 +USE baiyue_iam; + +-- 为存量数据库补齐企业内成员编号唯一约束,防止并发重复创建。 +SET @member_no_index_sql = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'iam_membership' + AND index_name = 'uk_membership_tenant_no'), + 'SELECT 1', + 'ALTER TABLE iam_membership ADD UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no)' +); +PREPARE member_no_index_stmt FROM @member_no_index_sql; +EXECUTE member_no_index_stmt; +DEALLOCATE PREPARE member_no_index_stmt; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) +VALUES + (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), + (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), + (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), + (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), + (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), + (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000101, id, 10001 FROM iam_permission +WHERE tenant_id = 10001 AND permission_code IN + ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000100, id, 0 FROM iam_permission +WHERE tenant_id = 0 AND permission_code IN + ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\rbac-role-management-v2.sql +-- ============================================================================ +SET NAMES utf8mb4; +-- 角色管理闭环权限迁移,可重复执行。 +USE baiyue_iam; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, sort_no, status, deleted) +VALUES + (5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', 10, 'ACTIVE', 0), + (5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', 30, 'ACTIVE', 0), + (5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', 40, 'ACTIVE', 0), + (5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0), + (5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', 40, 'ACTIVE', 0), + (5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配成员角色', 10, 'ACTIVE', 0), + (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', 20, 'ACTIVE', 0), + (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), + (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), + (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), + (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), + (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), + (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), + (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), + (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), + status = 'ACTIVE', deleted = 0; + +-- 示例管理员获得新增权限;其他角色仍由管理员按需授权。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete', + 'platform:member:list', 'platform:member:grant-role', 'platform:customer:create', + 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password', + 'platform:user:grant-role') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000101, id, 10001 FROM iam_permission +WHERE tenant_id = 10001 AND app_code = 'MERCHANT' + AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000100, id, 0 FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'MERCHANT' + AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase1-platform-master-data-and-permission-management.sql +-- ============================================================================ +-- 权限资源维护与平台主数据权限迁移。 +-- 在 phase1-admin-rbac.sql、rbac-role-management-v2.sql 之后执行,可重复执行。 +USE baiyue_iam; + +-- 兼容不支持 ADD COLUMN IF NOT EXISTS 的 MySQL 版本,通过元数据判断后执行。 +SET @iam_permission_built_in_sql = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_permission' AND column_name = 'built_in'), + 'SELECT 1', + "ALTER TABLE iam_permission ADD COLUMN built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置资源不可通过管理接口修改' AFTER status" +); +PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql; +EXECUTE iam_permission_built_in_stmt; +DEALLOCATE PREPARE iam_permission_built_in_stmt; + +-- 一期固定编号范围内的资源均为种子资源,运行期生成的雪花编号不作预置标记。 +UPDATE iam_permission +SET built_in = 1 +WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, sort_no, status, built_in, deleted) +VALUES + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', + '/downloads', 40, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + sort_no, status, built_in, deleted) +VALUES + (5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0), + (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0), + (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0), + (5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0), + (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0), + (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0), + (5101034, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:delete', '删除权限资源', 40, 'ACTIVE', 1, 0), + (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', 10, 'ACTIVE', 1, 0), + (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', 20, 'ACTIVE', 1, 0), + (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', 30, 'ACTIVE', 1, 0), + (5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0), + (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0), + (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0), + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', 10, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; + +-- 新增平台操作权限默认只授予系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role', + 'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete', + 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import', + 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status', + 'platform:customer:export', 'platform:download:list', 'platform:download:all' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 已有客户运营角色继续具备客户导出与本人下载任务查询能力,但不授予查看全部任务。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id +FROM iam_role_permission existing_grant +JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id +JOIN iam_permission added_permission + ON added_permission.tenant_id = 0 AND added_permission.app_code = 'PLATFORM' + AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list') +WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 详情查看从列表和审核权限中拆分;升级时保留既有运营角色的查询能力。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id +FROM iam_role_permission existing_grant +JOIN iam_permission existing_permission + ON existing_permission.id = existing_grant.permission_id +JOIN iam_permission view_permission + ON view_permission.tenant_id = 0 + AND view_permission.app_code = 'PLATFORM' + AND view_permission.permission_code = 'platform:customer:view' +WHERE existing_permission.tenant_id = 0 + AND existing_permission.app_code = 'PLATFORM' + AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review') +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase1-customer-profile.sql +-- ============================================================================ +-- 一期平台客户档案字段增量迁移。可在同一数据库重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'contact_wechat'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN contact_wechat VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'sales_owner_name'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN sales_owner_name VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'payment_term_days'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN payment_term_days SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'enterprise_type'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN enterprise_type VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'established_date'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN established_date DATE NULL COMMENT ''成立日期'' AFTER enterprise_type'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital DECIMAL(18,2) NULL COMMENT ''注册资本'' AFTER established_date'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital_currency'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital_currency CHAR(3) NULL COMMENT ''注册资本币种 ISO 4217'' AFTER registered_capital'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_address'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_address VARCHAR(500) NULL COMMENT ''注册地址文本'' AFTER registered_capital_currency'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'internal_remark'), + 'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN internal_remark VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ============================================================================ +-- 来源:order\src\main\resources\db\phase1-order-extension.sql +-- ============================================================================ +-- Phase-one first-leg order fields. Safe to run repeatedly on MySQL 8. +USE baiyue_order; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS phase1_add_column; +DELIMITER $$ +CREATE PROCEDURE phase1_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND COLUMN_NAME = p_column_name + ) THEN + SET @phase1_sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_definition); + PREPARE phase1_stmt FROM @phase1_sql; + EXECUTE phase1_stmt; + DEALLOCATE PREPARE phase1_stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT 'Pickup method'"); +CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT 'Domestic receiving warehouse ID'"); +CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT 'Domestic receiving warehouse snapshot'"); +CALL phase1_add_column('ord_order', 'receiving_warehouse_address', "VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照'"); +CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照'"); +CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'"); +CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'"); +CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'"); +CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT 'FULL_CONTAINER or LCL'"); +CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT 'Whether customs inspection is required'"); +CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT 'FBA, OVERSEAS or PRIVATE'"); +CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon warehouse code'"); +CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Amazon Shipment ID'"); +CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT 'Amazon Reference ID'"); +CALL phase1_add_column('ord_order', 'express_company', "VARCHAR(64) NULL COMMENT '履约快递公司名称快照'"); +CALL phase1_add_column('ord_order', 'express_tracking_no', "VARCHAR(128) NULL COMMENT '履约快递单号'"); + +CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT 'SKU number'"); +CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT 'UPC number'"); +CALL phase1_add_column('ord_order_cargo', 'cargo_name_en', "VARCHAR(128) NULL COMMENT 'English cargo name'"); +CALL phase1_add_column('ord_order_cargo', 'customs_hs_code', "VARCHAR(20) NULL COMMENT 'Destination customs HS code'"); +CALL phase1_add_column('ord_order_cargo', 'units_per_package', "INT UNSIGNED NULL COMMENT 'Units per carton'"); +CALL phase1_add_column('ord_order_cargo', 'total_quantity', "INT UNSIGNED NULL COMMENT 'Total units'"); +CALL phase1_add_column('ord_order_cargo', 'net_weight_kg', "DECIMAL(18,3) NULL COMMENT 'Net weight in kilograms'"); +CALL phase1_add_column('ord_order_cargo', 'unit_declared_value', "DECIMAL(18,2) NULL COMMENT 'Declared value per unit'"); +CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT 'Material'"); +CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'"); +CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'"); +CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'"); + +DROP PROCEDURE phase1_add_column; + +-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。 +CREATE TABLE IF NOT EXISTS ord_idempotency_record ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录 ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户 ID', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人 ID', + operation VARCHAR(32) NOT NULL COMMENT '操作类型', + resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源 ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(16) NOT NULL COMMENT '处理状态', + result_order_id BIGINT UNSIGNED NULL COMMENT '重放结果订单 ID', + completed_at DATETIME(3) NULL COMMENT '完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), + KEY idx_order_idempotency_created (created_at) +) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录'; + +-- ============================================================================ +-- 来源:order\src\main\resources\db\phase1-masterdata-extension.sql +-- ============================================================================ +SET NAMES utf8mb4; +USE baiyue_order; +-- MySQL 8 可重复执行的订单快照及履约字段迁移。 +SET @schema_name = DATABASE(); +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='receiving_warehouse_address')=0, + 'ALTER TABLE ord_order ADD COLUMN receiving_warehouse_address VARCHAR(600) NULL COMMENT ''收货仓地址历史快照'' AFTER receiving_warehouse_name','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='receiving_time')=0, + 'ALTER TABLE ord_order ADD COLUMN receiving_time VARCHAR(100) NULL COMMENT ''收货时间历史快照'' AFTER receiving_warehouse_address','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='express_company')=0, + 'ALTER TABLE ord_order ADD COLUMN express_company VARCHAR(64) NULL COMMENT ''履约快递公司'' AFTER reference_id','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@schema_name AND TABLE_NAME='ord_order' AND COLUMN_NAME='express_tracking_no')=0, + 'ALTER TABLE ord_order ADD COLUMN express_tracking_no VARCHAR(128) NULL COMMENT ''履约快递单号'' AFTER express_company','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +-- ============================================================================ +-- 来源:warehouse\src\main\resources\db\phase1-warehouse-schema-migration.sql +-- ============================================================================ +-- 既有 baiyue_warehouse 的一期增量迁移(只增不删,可重复执行)。 +-- 执行顺序:warehouse.sql、warehouse-partner.sql、warehouse-express-company.sql、 +-- 本文件、warehouse-seed.sql。应用不会在启动时自动执行这些脚本,必须由发布流程执行。 +SET NAMES utf8mb4; +USE baiyue_warehouse; + +-- MySQL 5.7/8.0 各小版本对条件补列语法的支持不一致。 +-- 统一先查 information_schema,再通过动态 SQL 补列,确保脚本可重复执行。 +DROP PROCEDURE IF EXISTS add_column_if_missing; +DELIMITER $$ +CREATE PROCEDURE add_column_if_missing( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @add_column_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE add_column_statement FROM @add_column_sql; + EXECUTE add_column_statement; + DEALLOCATE PREPARE add_column_statement; + END IF; +END$$ +DELIMITER ; + +-- 早期 wh_warehouse 缺少的业务与审计字段。 +CALL add_column_if_missing('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name'); +CALL add_column_if_missing('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type'); +CALL add_column_if_missing('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''Asia/Shanghai'' COMMENT ''IANA时区'' AFTER country_code'); +CALL add_column_if_missing('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone'); +CALL add_column_if_missing('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name'); +CALL add_column_if_missing('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone'); +CALL add_column_if_missing('wh_warehouse', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER contact_email'); +CALL add_column_if_missing('wh_warehouse', 'remark', 'VARCHAR(500) NULL AFTER status'); +CALL add_column_if_missing('wh_warehouse', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_warehouse', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL add_column_if_missing('wh_warehouse_address', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_warehouse_address', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); +CALL add_column_if_missing('wh_warehouse_address', 'address_type', 'VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' AFTER warehouse_id'); +CALL add_column_if_missing('wh_warehouse_address', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER address_type'); +CALL add_column_if_missing('wh_warehouse_address', 'state_province', 'VARCHAR(100) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'city', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER state_province'); +CALL add_column_if_missing('wh_warehouse_address', 'district', 'VARCHAR(100) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'postal_code', 'VARCHAR(20) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'address_line1', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER postal_code'); +CALL add_column_if_missing('wh_warehouse_address', 'address_line2', 'VARCHAR(255) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'latitude', 'DECIMAL(10,7) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'longitude', 'DECIMAL(10,7) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse_address', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse_address', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_warehouse_address', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL add_column_if_missing('wh_service_capability', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_service_capability', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); +CALL add_column_if_missing('wh_service_capability', 'capability_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER warehouse_id'); +CALL add_column_if_missing('wh_service_capability', 'capability_name', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER capability_code'); +CALL add_column_if_missing('wh_service_capability', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1'); +CALL add_column_if_missing('wh_service_capability', 'cutoff_time', 'TIME NULL'); +CALL add_column_if_missing('wh_service_capability', 'description', 'VARCHAR(500) NULL'); +CALL add_column_if_missing('wh_service_capability', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_service_capability', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_service_capability', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_service_capability', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_service_capability', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_service_capability', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); +CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); +CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); +CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); +CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); +CALL add_column_if_missing('wh_partner', 'province', 'VARCHAR(64) NULL'); +CALL add_column_if_missing('wh_partner', 'city', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER province'); +CALL add_column_if_missing('wh_partner', 'district', 'VARCHAR(64) NULL'); +CALL add_column_if_missing('wh_partner', 'address_line', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER district'); +CALL add_column_if_missing('wh_partner', 'postal_code', 'VARCHAR(20) NULL'); +CALL add_column_if_missing('wh_partner', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER postal_code'); +CALL add_column_if_missing('wh_partner', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_partner', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); + +-- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 +CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_express_company', 'company_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); +CALL add_column_if_missing('wh_express_company', 'company_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER company_code'); +CALL add_column_if_missing('wh_express_company', 'service_phone', 'VARCHAR(32) NULL'); +CALL add_column_if_missing('wh_express_company', 'website', 'VARCHAR(255) NULL'); +CALL add_column_if_missing('wh_express_company', 'tracking_url_template', 'VARCHAR(500) NULL'); +CALL add_column_if_missing('wh_express_company', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER tracking_url_template'); +CALL add_column_if_missing('wh_express_company', 'remark', 'VARCHAR(500) NULL'); +CALL add_column_if_missing('wh_express_company', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_express_company', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_express_company', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_express_company', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_express_company', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_express_company', 'version', 'INT NOT NULL DEFAULT 0'); + +DROP PROCEDURE IF EXISTS add_column_if_missing; + +-- MySQL 8 的 ALTER ADD INDEX 不在所有小版本都支持 IF NOT EXISTS,使用元数据判断以保证重复执行安全。 +SET @schema_name = DATABASE(); +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_warehouse' AND index_name = 'idx_wh_tenant_status') = 0, + 'ALTER TABLE wh_warehouse ADD KEY idx_wh_tenant_status (tenant_id, status, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_warehouse' AND index_name = 'idx_wh_tenant_type_status') = 0, + 'ALTER TABLE wh_warehouse ADD KEY idx_wh_tenant_type_status (tenant_id, warehouse_type, status, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_warehouse_address' AND index_name = 'idx_address_warehouse') = 0, + 'ALTER TABLE wh_warehouse_address ADD KEY idx_address_warehouse (tenant_id, warehouse_id, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_service_capability' AND index_name = 'idx_capability_query') = 0, + 'ALTER TABLE wh_service_capability ADD KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_partner' AND index_name = 'idx_wh_partner_tenant_status') = 0, + 'ALTER TABLE wh_partner ADD KEY idx_wh_partner_tenant_status (tenant_id, status, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'idx_wh_express_tenant_status') = 0, + 'ALTER TABLE wh_express_company ADD KEY idx_wh_express_tenant_status (tenant_id, status, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_partner' AND index_name = 'uk_wh_partner_tenant_code') = 0, + 'ALTER TABLE wh_partner ADD UNIQUE KEY uk_wh_partner_tenant_code (tenant_id, partner_code, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_code') = 0, + 'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_name') = 0, + 'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase1-firstleg-task-list-index.sql +-- ============================================================================ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 既有履约任务表补充分页查询索引;重复执行时不重复创建。 +SET @firstleg_task_list_index_sql = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_fulfillment_task' + AND index_name = 'idx_task_tenant_created'), + 'SELECT 1', + 'CREATE INDEX idx_task_tenant_created ON fl_fulfillment_task (tenant_id, created_at)' +); +PREPARE firstleg_task_list_index_stmt FROM @firstleg_task_list_index_sql; +EXECUTE firstleg_task_list_index_stmt; +DEALLOCATE PREPARE firstleg_task_list_index_stmt; + +-- ============================================================================ +-- 来源:warehouse\src\main\resources\db\phase2-pallet-location-schema.sql +-- ============================================================================ +-- 二期托盘、库位、预占与占用表结构。 +-- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。 +-- 可在 warehouse.sql 和一期增量脚本之后执行;所有建表均可重复执行。 +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + pallet_name VARCHAR(100) NULL COMMENT '托盘名称', + capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', + remark VARCHAR(500) 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_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code), + KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), + KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), + CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) +) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NULL COMMENT '库区编码', + zone_name VARCHAR(100) NULL COMMENT '库区名称', + location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码', + location_name VARCHAR(100) NULL COMMENT '库位名称', + location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET', + max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为系统共享溢出位A0', + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED', + remark VARCHAR(500) 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_location_tenant_warehouse_code (tenant_id, warehouse_id, location_code), + KEY idx_location_available (tenant_id, warehouse_id, status, deleted), + KEY idx_location_zone (tenant_id, warehouse_id, zone_code, status, deleted), + CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT ck_location_max_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) +) ENGINE=InnoDB COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验'; + +CREATE TABLE IF NOT EXISTS wh_location_reservation ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键', + source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', + business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', + inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', + location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', + reservation_status VARCHAR(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED', + expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间', + confirmed_at DATETIME(3) NULL COMMENT '确认占用时间', + released_at DATETIME(3) NULL COMMENT '释放时间', + release_reason VARCHAR(500) NULL COMMENT '释放或过期原因', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记', + active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', + PRIMARY KEY (id), + UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), + UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), + UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), + UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), + KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), + KEY idx_location_reservation_expire (reservation_status, expires_at), + KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), + CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), + CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) +) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; + +CREATE TABLE IF NOT EXISTS wh_location_occupancy ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号', + reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID', + source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务', + business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID', + inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', + location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否使用共享溢出位A0', + allocated_volume_cbm DECIMAL(18,6) NOT NULL COMMENT '本托盘分配体积CBM', + allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', + occupancy_status VARCHAR(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED', + occupied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间', + released_at DATETIME(3) NULL COMMENT '释放时间', + release_reason VARCHAR(500) NULL COMMENT '释放原因', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', + active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', + PRIMARY KEY (id), + UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), + UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), + UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), + UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), + KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), + KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), + CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), + CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), + CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), + CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) +) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; + +-- 头程入库单属于其他数据库,不建立跨库外键。 + +-- ============================================================================ +-- 来源:warehouse\src\main\resources\db\phase2-location-overflow-migration.sql +-- ============================================================================ +-- 二期库位共享溢出位 A0 增量迁移。 +-- 执行顺序:phase2-pallet-location-schema.sql、本文件。 +-- 普通库位仍保持一托一位;同一租户仓库只有一个 A0,且 A0 可承载多个活动资源。 +SET NAMES utf8mb4; +USE baiyue_warehouse; + +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; +DELIMITER $$ +CREATE PROCEDURE add_location_overflow_column_if_missing( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @column_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition + ); + PREPARE column_statement FROM @column_sql; + EXECUTE column_statement; + DEALLOCATE PREPARE column_statement; + END IF; +END$$ +DELIMITER ; + +CALL add_location_overflow_column_if_missing( + 'wh_location', 'shared_overflow', + 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否为系统共享溢出位 A0'' AFTER max_weight_kg' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_reservation', 'shared_overflow', + 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_occupancy', 'shared_overflow', + 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否使用共享溢出位 A0'' AFTER location_code' +); + +-- 兼容迁移前人工或旧流程已创建的 A0,并将资源快照回填一致。 +UPDATE wh_location + SET shared_overflow = 1, + status = 'AVAILABLE' + WHERE location_code = 'A0' AND deleted = 0; + +UPDATE wh_location_reservation r +JOIN wh_location l + ON l.tenant_id = r.tenant_id AND l.id = r.location_id + SET r.shared_overflow = l.shared_overflow; + +UPDATE wh_location_occupancy o +JOIN wh_location l + ON l.tenant_id = o.tenant_id AND l.id = o.location_id + SET o.shared_overflow = l.shared_overflow; + +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; + +-- 仅旧生成列需要移除并重建唯一索引;已经是最终定义时必须跳过,避免重复部署产生约束空窗。 +SET @schema_name = DATABASE(); +SET @reservation_needs_upgrade = IFNULL(( + SELECT IF(LOCATE('shared_overflow', generation_expression) = 0, 1, 0) + FROM information_schema.columns + WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' + AND column_name = 'active_reservation' +), 1); +SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @sql = IF(@reservation_needs_upgrade = 1, + 'ALTER TABLE wh_location_reservation MODIFY COLUMN active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动预占唯一标记,A0 返回 NULL''', + 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') = 0, + 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @occupancy_needs_upgrade = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' + AND column_name = 'active_location_occupancy' + AND LOCATE('shared_overflow', generation_expression) > 0) + AND EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' + AND column_name = 'active_pallet_occupancy'), + 0, 1); +SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; +DELIMITER $$ +CREATE PROCEDURE add_location_overflow_column_if_missing( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @column_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition + ); + PREPARE column_statement FROM @column_sql; + EXECUTE column_statement; + DEALLOCATE PREPARE column_statement; + END IF; +END$$ +DELIMITER ; + +CALL add_location_overflow_column_if_missing( + 'wh_location_reservation', 'active_pallet_reservation', + 'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)''' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_occupancy', 'active_location_occupancy', + 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记''' +); +CALL add_location_overflow_column_if_missing( + 'wh_location_occupancy', 'active_pallet_occupancy', + 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)''' +); +DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0, + 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0, + 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0, + 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +-- ============================================================================ +-- 来源:order\src\main\resources\db\phase2-order-carrier-migration.sql +-- ============================================================================ +SET NAMES utf8mb4; +USE baiyue_order; + +SET @carrier_id_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'carrier_id' +); +SET @carrier_id_sql := IF(@carrier_id_exists = 0, + 'ALTER TABLE ord_order ADD COLUMN carrier_id BIGINT UNSIGNED NULL COMMENT ''船司逻辑ID快照'' AFTER channel_name', + 'SELECT 1'); +PREPARE carrier_id_statement FROM @carrier_id_sql; +EXECUTE carrier_id_statement; +DEALLOCATE PREPARE carrier_id_statement; + +SET @carrier_name_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'carrier_name' +); +SET @carrier_name_sql := IF(@carrier_name_exists = 0, + 'ALTER TABLE ord_order ADD COLUMN carrier_name VARCHAR(128) NULL COMMENT ''船司名称快照'' AFTER carrier_id', + 'SELECT 1'); +PREPARE carrier_name_statement FROM @carrier_name_sql; +EXECUTE carrier_name_statement; +DEALLOCATE PREPARE carrier_name_statement; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase2-inbound-schema.sql +-- ============================================================================ +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_firstleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_inbound_order ( + id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID', + order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', + order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', + inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码', + process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态', + order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', + measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态', + fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态', + fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', + notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态', + expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', + customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID', + company_name VARCHAR(128) NULL COMMENT '运营公司名称快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID', + warehouse_name VARCHAR(128) NOT NULL COMMENT '实际作业仓库名称快照', + delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', + delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + channel_code VARCHAR(64) NULL COMMENT '渠道编码', + channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', + carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', + carrier_name VARCHAR(128) NULL COMMENT '计费船司名称快照', + tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式', + container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式', + customs_inspection TINYINT(1) NULL COMMENT '是否商检', + destination_type VARCHAR(24) NULL COMMENT '目的仓类型', + destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID', + destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码', + destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', + hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', + current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', + total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', + actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', + chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', + actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', + chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', + pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照', + volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg', + actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg', + measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间', + inbounded_at DATETIME(3) NULL COMMENT '入库完成时间', + express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照', + express_company_name VARCHAR(128) NULL COMMENT '快递公司名称快照', + express_tracking_no VARCHAR(128) NULL COMMENT '快递单号快照', + public_remark VARCHAR(1000) NULL COMMENT '客户可见备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + 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_inbound_tenant_code (tenant_id, inbound_code), + UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id), + KEY idx_inbound_process_time (tenant_id, process_status, created_at, id), + KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id), + KEY idx_inbound_fee_time (tenant_id, fee_review_status, created_at, id), + KEY idx_inbound_warehouse_status (tenant_id, warehouse_id, process_status, created_at), + KEY idx_inbound_customer_time (tenant_id, customer_id, created_at), + KEY idx_inbound_company_time (tenant_id, company_id, created_at), + KEY idx_inbound_arrival (tenant_id, expected_arrival_at), + KEY idx_inbound_destination (tenant_id, destination_code, created_at), + KEY idx_inbound_channel (tenant_id, channel_code, created_at), + CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) +) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; + +CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot ( + id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + pickup_method VARCHAR(24) NULL COMMENT '提货方式', + pickup_at DATETIME(3) NULL COMMENT '提货时间', + contact_name VARCHAR(64) NULL COMMENT '提货联系人', + contact_phone VARCHAR(32) NULL COMMENT '提货联系人电话', + contact_email VARCHAR(128) NULL COMMENT '提货联系人邮箱', + country_code CHAR(2) NULL COMMENT '国家二字码', + province VARCHAR(64) NULL COMMENT '省或州', + city VARCHAR(64) NULL COMMENT '城市', + district VARCHAR(64) NULL COMMENT '区县', + address_line VARCHAR(512) NULL COMMENT '提货详细地址', + postal_code VARCHAR(20) NULL COMMENT '提货邮编', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pickup_inbound (tenant_id, inbound_id), + CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库单提货信息不可变快照'; + +CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot ( + id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + destination_type VARCHAR(24) NULL COMMENT '目的仓类型', + destination_code VARCHAR(32) NULL COMMENT '目的仓编码', + destination_name VARCHAR(128) NULL COMMENT '目的仓名称', + country_code CHAR(2) NULL COMMENT '国家二字码', + state_province VARCHAR(100) NULL COMMENT '省或州', + city VARCHAR(100) NULL COMMENT '城市', + address_line VARCHAR(512) NULL COMMENT '详细地址', + postal_code VARCHAR(20) NULL COMMENT '邮编', + contact_name VARCHAR(64) NULL COMMENT '联系人', + contact_phone VARCHAR(32) NULL COMMENT '联系人电话', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_destination_inbound (tenant_id, inbound_id), + CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库单目的仓地址和联系人快照'; + +CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot ( + id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + loading_status VARCHAR(24) NULL COMMENT '配载状态快照', + container_no VARCHAR(64) NULL COMMENT '集装箱号', + cabinet_no VARCHAR(64) NULL COMMENT '柜号或内部柜单号', + vessel_name VARCHAR(128) NULL COMMENT '船名', + voyage_no VARCHAR(64) NULL COMMENT '航次', + sailing_at DATETIME(3) NULL COMMENT '开船时间', + port_arrival_at DATETIME(3) NULL COMMENT '到港时间', + source_updated_at DATETIME(3) NULL COMMENT '上游数据更新时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_transport_inbound (tenant_id, inbound_id), + KEY idx_transport_container (tenant_id, container_no), + KEY idx_transport_cabinet (tenant_id, cabinet_no), + KEY idx_transport_loading (tenant_id, loading_status, sailing_at), + CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照'; + +CREATE TABLE IF NOT EXISTS fl_inbound_measurement ( + id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL', + source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码', + external_event_no VARCHAR(128) NULL COMMENT '第三方事件号', + external_request_hash CHAR(64) NULL COMMENT '第三方data原文和图片内容联合请求哈希', + device_code VARCHAR(64) NULL COMMENT '测量设备编码', + warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码', + measured_at DATETIME(3) NULL COMMENT '设备测量时间', + status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态', + raw_payload_json JSON NULL COMMENT '第三方原始报文', + original_document_no VARCHAR(64) NULL COMMENT '原单据号码快照', + original_gross_weight_kg DECIMAL(18,3) NULL COMMENT '原单据毛重kg', + original_volume_cbm DECIMAL(18,4) NULL COMMENT '原单据体积或材积m3', + actual_goods_name VARCHAR(255) NULL COMMENT '实际品名', + total_package_count INT UNSIGNED NULL COMMENT '本批次总箱数', + total_volume_raw_cbm DECIMAL(18,6) NULL COMMENT '本批次原始总体积m3', + total_volume_cbm DECIMAL(18,4) NULL COMMENT '本批次业务总体积m3', + volume_weight_kg DECIMAL(18,3) NULL COMMENT '本批次总材积重kg', + created_by BIGINT UNSIGNED NULL COMMENT '创建人或系统主体ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no), + KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at), + CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据'; + +CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID', + inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空', + external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空', + item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号', + box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识', + package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数', + length_cm DECIMAL(12,3) NOT NULL COMMENT '原始长度cm', + width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm', + height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm', + girth_cm DECIMAL(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', + unit_weight_kg DECIMAL(18,3) NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', + unit_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '单箱业务体积m3', + volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3', + volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3', + total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', + total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', + volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', + calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', + source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', + over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照', + outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm', + outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm', + outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm', + remark VARCHAR(500) NULL COMMENT '测量明细备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no), + UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no), + CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) +) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细'; + +CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( + id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', + ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', + ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', + ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', + ocr_weight_unit VARCHAR(8) NULL COMMENT 'OCR识别原始单位', + confidence DECIMAL(6,5) NULL COMMENT '识别置信度', + adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', + adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', + final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', + recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态', + adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', + adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', + adjusted_at DATETIME(3) NULL COMMENT '修正时间', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', + confirmed_at DATETIME(3) 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_weight_inbound_file (tenant_id, inbound_id, file_id), + KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), + CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; + +CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( + id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', + revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', + action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', + before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', + before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', + after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', + after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', + final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + reason VARCHAR(500) NULL COMMENT '人工修正原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), + KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), + CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) +) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史'; + +CREATE TABLE IF NOT EXISTS fl_measure_review_record ( + id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID', + review_result VARCHAR(24) NOT NULL COMMENT '审核结果', + review_reason VARCHAR(1000) NULL COMMENT '审核或驳回原因', + exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常放行', + release_reason VARCHAR(1000) NULL COMMENT '异常放行原因', + allow_rejected_config TINYINT(1) NOT NULL DEFAULT 0 COMMENT '操作时Nacos异常放行开关值', + reviewer_id BIGINT UNSIGNED NOT NULL COMMENT '审核人ID', + reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照', + reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间', + PRIMARY KEY (id), + KEY idx_review_inbound_time (tenant_id, inbound_id, reviewed_at), + KEY idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at), + CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id) +) ENGINE=InnoDB COMMENT='测量审核、驳回和异常放行不可变记录'; + +CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( + id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码', + rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID', + customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', + tax_mode VARCHAR(24) NULL COMMENT '包税或不包税类型', + conversion_factor DECIMAL(18,6) NOT NULL COMMENT '重量体积换算系数', + rounding_scale TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '结果保留小数位数', + rounding_mode VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式', + currency CHAR(3) NOT NULL COMMENT '默认币种', + effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', + effective_to DATETIME(3) NULL COMMENT '生效结束时间', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态', + remark VARCHAR(500) 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_charge_rule_version (tenant_id, rule_code, rule_version), + KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to) +) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; + +CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( + id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', + rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', + origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', + origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', + business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型', + channel_code VARCHAR(64) NULL COMMENT '渠道编码', + carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID', + carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', + destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码', + charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM', + unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价', + currency CHAR(3) NOT NULL COMMENT '币种', + additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费', + effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间', + effective_to DATETIME(3) NULL COMMENT '生效结束时间', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态', + remark VARCHAR(500) 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_fba_rate_version (tenant_id, rate_code, rate_version), + KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to) +) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'; + +CREATE TABLE IF NOT EXISTS fl_inbound_fee ( + id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号', + fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', + charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID', + charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照', + carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', + carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', + rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', + customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', + tax_mode VARCHAR(24) NULL COMMENT '包税模式快照', + rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照', + rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照', + rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照', + rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照', + rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照', + origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照', + origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', + channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照', + carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照', + carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照', + destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照', + rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照', + rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照', + charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM', + conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照', + actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', + actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', + converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', + chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', + charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', + currency CHAR(3) NOT NULL COMMENT '币种', + subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额', + additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额', + total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额', + fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态', + review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态', + exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成', + calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID', + calculated_at DATETIME(3) NOT NULL COMMENT '试算时间', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', + sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID', + sent_at DATETIME(3) NULL COMMENT '发送客户时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_fee_no (tenant_id, fee_no), + UNIQUE KEY uk_inbound_fee_version (tenant_id, inbound_id, fee_version), + KEY idx_inbound_fee_status (tenant_id, inbound_id, fee_status, review_status), + CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id), + CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) +) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; + +CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( + id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID', + item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号', + fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码', + fee_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照', + quantity DECIMAL(18,4) NOT NULL COMMENT '计费数量', + unit VARCHAR(16) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,6) NOT NULL COMMENT '单价', + amount DECIMAL(18,2) NOT NULL COMMENT '金额', + currency CHAR(3) NOT NULL COMMENT '币种', + automatic_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动计算', + remark VARCHAR(500) NULL COMMENT '费用备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fee_item_no (tenant_id, fee_id, item_no), + CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id) +) ENGINE=InnoDB COMMENT='入库头程费用项目明细'; + +CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( + id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID', + warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积', + allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数', + location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID', + location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', + reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号', + allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), + UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), + KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), + CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; + +CREATE TABLE IF NOT EXISTS fl_inbound_file ( + id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID', + file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT', + file_name_snapshot VARCHAR(255) NOT NULL COMMENT '文件名快照', + visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围', + file_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '业务文件版本', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传人ID', + uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_file_relation (tenant_id, inbound_id, file_id, file_type), + KEY idx_inbound_file_type (tenant_id, inbound_id, file_type, deleted), + CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库单与文件中心对象的分类关系'; + +CREATE TABLE IF NOT EXISTS fl_inbound_hold_record ( + id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE', + reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因', + evidence_file_id BIGINT UNSIGNED NULL COMMENT '证据文件ID', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + KEY idx_hold_inbound_time (tenant_id, inbound_id, operated_at), + CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库单扣货和解除扣货不可变历史'; + +CREATE TABLE IF NOT EXISTS fl_inbound_internal_note ( + id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_internal_note_time (tenant_id, inbound_id, created_at), + CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库单客服内部备注历史'; + +CREATE TABLE IF NOT EXISTS fl_inbound_status_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + from_status VARCHAR(24) NULL COMMENT '变更前状态', + to_status VARCHAR(24) NOT NULL COMMENT '变更后状态', + action VARCHAR(32) NOT NULL COMMENT '触发动作', + reason VARCHAR(1000) NULL COMMENT '变更原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (id), + KEY idx_inbound_history_time (tenant_id, inbound_id, occurred_at), + CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) +) ENGINE=InnoDB COMMENT='入库单流程状态不可变历史'; + +CREATE TABLE IF NOT EXISTS fl_inbound_outbox ( + id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID', + aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', + aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + payload_json JSON NOT NULL COMMENT '事件负载快照', + publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + published_at DATETIME(3) NULL COMMENT '发布时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_inbound_outbox_publish (publish_status, next_retry_at, created_at) +) ENGINE=InnoDB COMMENT='入库事务Outbox事件表'; + +CREATE TABLE IF NOT EXISTS fl_inbound_idempotency ( + id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + operation VARCHAR(40) NOT NULL COMMENT '操作类型', + operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID', + inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0', + idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(16) NOT NULL COMMENT '处理状态', + result_json JSON NULL COMMENT '成功结果摘要', + result_resource_id BIGINT UNSIGNED NULL COMMENT '成功结果资源ID', + completed_at DATETIME(3) NULL COMMENT '完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key), + KEY idx_inbound_idempotency_created (created_at) +) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录'; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase2-inbound-measurement-maintenance.sql +-- ============================================================================ +-- 二期入库测量与 OCR 维护增量(只增不删,可重复执行)。 +-- 执行顺序:phase2-inbound-schema.sql 后执行本文件;已有二期库只需执行本文件。 +SET NAMES utf8mb4; +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS phase2_measure_add_column; +DELIMITER $$ +CREATE PROCEDURE phase2_measure_add_column( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @phase2_measure_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition + ); + PREPARE phase2_measure_statement FROM @phase2_measure_sql; + EXECUTE phase2_measure_statement; + DEALLOCATE PREPARE phase2_measure_statement; + END IF; +END$$ +DELIMITER ; + +CALL phase2_measure_add_column('fl_inbound_measurement', 'original_document_no', + 'VARCHAR(64) NULL COMMENT ''原单据号码快照'' AFTER raw_payload_json'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'original_gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''原单据毛重kg'' AFTER original_document_no'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'original_volume_cbm', + 'DECIMAL(18,4) NULL COMMENT ''原单据体积或材积m3'' AFTER original_gross_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'actual_goods_name', + 'VARCHAR(255) NULL COMMENT ''实际品名'' AFTER original_volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'volume_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''本批次总材积重kg'' AFTER total_volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement', 'version', + 'INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号'' AFTER updated_at'); + +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification', + 'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm', + 'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm', + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm', + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg', + 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning', + 'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_length_cm', + 'DECIMAL(12,3) NULL COMMENT ''外箱规长度cm'' AFTER over_limit_warning'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', + 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', + 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); + +DROP PROCEDURE IF EXISTS phase2_measure_add_column; + +CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( + id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID', + revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致', + action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', + before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量', + before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', + after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', + after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', + final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + reason VARCHAR(500) NULL COMMENT '人工修正原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (id), + UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no), + KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at), + CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id), + CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id) +) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史'; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase2-inbound-weight-audit-migration.sql +-- ============================================================================ +-- 二期称重结果审计字段增量迁移(可重复执行)。 +-- InboundWeightResultEntity 继承 BaseEntity,持久化需要 created_by、updated_by 两个审计字段。 +SET @has_created_by := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'created_by' +); +SET @created_by_sql := IF(@has_created_by = 0, + 'ALTER TABLE fl_inbound_weight_result ADD COLUMN created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''创建人ID'' AFTER confirmed_at', + 'SELECT 1'); +PREPARE created_by_stmt FROM @created_by_sql; +EXECUTE created_by_stmt; +DEALLOCATE PREPARE created_by_stmt; + +SET @has_updated_by := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' AND column_name = 'updated_by' +); +SET @updated_by_sql := IF(@has_updated_by = 0, + 'ALTER TABLE fl_inbound_weight_result ADD COLUMN updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''最后修改人ID'' AFTER created_at', + 'SELECT 1'); +PREPARE updated_by_stmt FROM @updated_by_sql; +EXECUTE updated_by_stmt; +DEALLOCATE PREPARE updated_by_stmt; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase2-inbound-workflow-migration.sql +-- ============================================================================ +-- 业务块4增量迁移:既有二期库补齐异常放行关口查询索引。 +-- 可重复执行;新库仍先执行 phase2-inbound-schema.sql。 +USE baiyue_firstleg; +SET NAMES utf8mb4; + +SET @ddl = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_measure_review_record' + AND index_name = 'idx_review_exception_release'), + 'SELECT 1', + 'ALTER TABLE fl_measure_review_record ADD INDEX idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at)' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase2-measurement-openapi.sql +-- ============================================================================ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。 +CREATE TABLE IF NOT EXISTS fl_third_party_call_log ( + id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0', + app_key VARCHAR(128) NULL COMMENT '第三方应用标识', + signature_subject VARCHAR(128) NULL COMMENT '签名主体快照', + request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法', + request_path VARCHAR(255) NOT NULL COMMENT '签名使用的规范路径', + request_timestamp VARCHAR(32) NULL COMMENT '请求头Unix毫秒时间戳原值', + nonce VARCHAR(128) NULL COMMENT '请求Nonce', + idempotency_key VARCHAR(128) NULL COMMENT 'HTTP幂等键,查询接口为空', + idempotency_anchor TINYINT UNSIGNED NULL COMMENT '签名通过后首个请求为1,重复调用为空', + signature_value VARCHAR(128) NULL COMMENT '第三方签名原值', + request_hash CHAR(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制', + raw_payload LONGTEXT NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体', + processing_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED', + result_code VARCHAR(32) NULL COMMENT '统一响应业务码', + result_message VARCHAR(1000) NULL COMMENT '处理结果或失败原因', + result_json JSON NULL COMMENT '首次成功结果快照', + requested_at DATETIME(3) NOT NULL COMMENT '服务端接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (id), + KEY idx_third_call_app_time (tenant_id, app_key, requested_at), + KEY idx_third_call_idempotency (tenant_id, app_key, idempotency_key), + KEY idx_third_call_nonce (tenant_id, app_key, nonce), + UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor) +) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase2-third-party-single-measurement-migration.sql +-- ============================================================================ +-- 二期第三方单条箱规 + 单张图片绑定增量;可重复执行。 +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS phase2_add_measurement_column; +DELIMITER $$ +CREATE PROCEDURE phase2_add_measurement_column( + IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); + PREPARE statement FROM @ddl; + EXECUTE statement; + DEALLOCATE PREPARE statement; + END IF; +END$$ +DELIMITER ; + +CALL phase2_add_measurement_column( + 'fl_inbound_measurement', 'external_request_hash', + 'CHAR(64) NULL COMMENT ''第三方data原文和图片内容联合请求哈希'' AFTER external_event_no'); +CALL phase2_add_measurement_column( + 'fl_inbound_measurement_item', 'inbound_detail_id', + 'BIGINT UNSIGNED NULL COMMENT ''绑定的订单货物明细ID,平台手工明细可为空'' AFTER measurement_id'); +CALL phase2_add_measurement_column( + 'fl_inbound_measurement_item', 'external_detail_no', + 'VARCHAR(128) NULL COMMENT ''第三方测量明细唯一号,平台手工明细可为空'' AFTER inbound_detail_id'); + +DROP PROCEDURE IF EXISTS phase2_add_measurement_column; + +SET @has_index = ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'fl_inbound_measurement_item' + AND INDEX_NAME = 'uk_measure_external_detail' +); +SET @ddl = IF( + @has_index = 0, + 'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no)', + 'SELECT 1' +); +PREPARE statement FROM @ddl; +EXECUTE statement; +DEALLOCATE PREPARE statement; + +-- ============================================================================ +-- 来源:firstleg\src\main\resources\db\phase2-fba-pricing-migration.sql +-- ============================================================================ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 二期 FBA 计费增量迁移。通过 information_schema 判断,允许重复部署。 +DROP PROCEDURE IF EXISTS phase2_fba_add_column; +DELIMITER $$ +CREATE PROCEDURE phase2_fba_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column + ) THEN + SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); + PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID' AFTER channel_name"); +CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id"); +CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); +CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name"); +CALL phase2_fba_add_column('fl_inbound_fee', 'tax_mode', "VARCHAR(24) NULL COMMENT '包税模式快照' AFTER business_type"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_scale', "TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '计费量保留位数快照' AFTER tax_mode"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_mode', "VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式快照' AFTER rounding_scale"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户规则生效开始快照' AFTER rounding_mode"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_to', "DATETIME(3) NULL COMMENT '客户规则生效结束快照' AFTER rule_effective_from"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rate_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '船司价格编码快照' AFTER rule_effective_to"); +CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '起运仓ID快照' AFTER rate_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '起运仓名称快照' AFTER origin_warehouse_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'channel_code', "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道编码快照' AFTER origin_warehouse_name"); +CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '船司ID快照' AFTER channel_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '船司名称快照' AFTER carrier_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code"); +CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); +CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); +CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', + "BIGINT UNSIGNED NULL COMMENT '仓储资源所属租户ID' AFTER warehouse_id"); + +-- 旧分配记录创建于共享仓储资源租户拆分前,按原业务租户回填后再收口非空约束。 +UPDATE fl_inbound_pallet_allocation +SET warehouse_resource_tenant_id = tenant_id +WHERE warehouse_resource_tenant_id IS NULL OR warehouse_resource_tenant_id = 0; +SET @resource_tenant_nullable = (SELECT IS_NULLABLE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_inbound_pallet_allocation' + AND COLUMN_NAME = 'warehouse_resource_tenant_id'); +SET @sql = IF(@resource_tenant_nullable = 'YES', + "ALTER TABLE fl_inbound_pallet_allocation MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储资源所属租户ID'", + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +DROP PROCEDURE IF EXISTS phase2_fba_add_column; + +-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match'); +SET @sql = IF(@idx_exists = 0, + 'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase2-inbound-query-permissions.sql +-- ============================================================================ +-- 二期入库单列表和详情只读权限,可在一期 RBAC 脚本后重复执行。 +USE baiyue_iam; + +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 + (5102030, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:inbound:list', + '入库单', '/inbounds', 'GET', 30, 'ACTIVE', 1, 0), + (5102031, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:view', + '查看入库单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + route_path = VALUES(route_path), http_method = VALUES(http_method), + 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.permission_code IN ( + 'platform:firstleg:inbound:list', 'platform:firstleg:inbound:view') +WHERE role.tenant_id = 0 + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase2-inbound-measurement-permissions.sql +-- ============================================================================ +-- 二期入库测量平台菜单和按钮权限,可重复执行。 +SET NAMES utf8mb4; +USE baiyue_iam; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, http_method, sort_no, status, deleted) +VALUES + (5102041, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:view', '查看入库测量', NULL, NULL, 10, 'ACTIVE', 0), + (5102042, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:edit', '保存测量草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5102043, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:measure:submit', '确认并提交测量', NULL, NULL, 20, 'ACTIVE', 0), + (5102044, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:weight:edit', '维护OCR重量', NULL, NULL, 30, 'ACTIVE', 0), + (5102045, 0, 'PLATFORM', 'BUTTON', 5102030, + 'platform:firstleg:inbound:weight:confirm', '确认OCR重量', NULL, NULL, 40, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase2-inbound-permissions.sql +-- ============================================================================ +-- 二期入库审核与异常放行按钮权限,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5102051, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:measurement-review', + '测量审核', NULL, NULL, 10, 'ACTIVE', 0), + (5102052, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:measurement-review-history', + '查看测量审核历史', NULL, NULL, 20, 'ACTIVE', 0), + (5102053, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:exception-release', + '测量异常放行', NULL, NULL, 30, 'ACTIVE', 0), + (5102054, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:send-fee', + '发送费用并入库', NULL, NULL, 40, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + status = 'ACTIVE', deleted = 0; + +-- 仅给平台超级管理员补齐默认权限;业务角色由 IAM 页面按职责授权。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, p.id, 0 +FROM iam_permission p +WHERE p.id BETWEEN 5102051 AND 5102054 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 客服(订单运营)负责测量审核;费用发送由审核通过后的服务端编排自动执行。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, p.id, 0 +FROM iam_permission p +WHERE p.id IN (5102051, 5102052) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase2-fba-pricing-permissions.sql +-- ============================================================================ +-- 二期 FBA 计费权限;在入库查询、测量和审核权限脚本后执行,可重复部署。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5102061, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rule:view', '查看客户计费规则', NULL, NULL, 10, 'ACTIVE', 1, 0), + (5102062, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rule:edit', '维护客户计费规则', NULL, NULL, 20, 'ACTIVE', 1, 0), + (5102063, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rate:view', '查看承运商价格', NULL, NULL, 30, 'ACTIVE', 1, 0), + (5102064, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:rate:edit', '维护承运商价格', NULL, NULL, 40, 'ACTIVE', 1, 0), + (5102065, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:calculate', '计算入库费用', NULL, NULL, 50, 'ACTIVE', 1, 0), + (5102066, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:confirm', '内部确认入库费用', NULL, NULL, 60, 'ACTIVE', 1, 0), + (5102067, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:view', '查看入库费用', NULL, NULL, 70, 'ACTIVE', 1, 0), + (5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, '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 4000001, id, 0 FROM iam_permission +WHERE id BETWEEN 5102061 AND 5102068 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +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); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase2-location-permissions.sql +-- ============================================================================ +-- 二期库位管理菜单和按钮权限。 +-- 在 phase1-admin-rbac.sql、phase1-platform-master-data-and-permission-management.sql 之后执行,可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list', '库位管理', + '/master-data/locations', NULL, 40, 'ACTIVE', 1, 0), + (5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:create', '新增库位', + NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103042, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:edit', '编辑库位', + NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103043, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:status', '启停库位', + NULL, NULL, 30, 'ACTIVE', 1, 0), + (5103044, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:delete', '删除库位', + NULL, NULL, 40, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0; + +-- 二期新增库位权限默认只授予平台系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:location:list', 'platform:location:create', 'platform:location:edit', + 'platform:location:status', 'platform:location:delete' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 来源:iam\src\main\resources\db\phase2-pallet-permissions.sql +-- ============================================================================ +-- 二期托盘管理菜单和按钮权限。 +-- 在 phase1-admin-rbac.sql、phase1-platform-master-data-and-permission-management.sql 之后执行,可重复执行。 +USE baiyue_iam; +SET NAMES utf8mb4; + +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 + (5103050, 0, 'PLATFORM', 'MENU', 5103000, 'platform:pallet:list', '托盘管理', + '/master-data/pallets', NULL, 50, 'ACTIVE', 1, 0), + (5103051, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:create', '新增托盘', + NULL, NULL, 10, 'ACTIVE', 1, 0), + (5103052, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:edit', '编辑托盘', + NULL, NULL, 20, 'ACTIVE', 1, 0), + (5103054, 0, 'PLATFORM', 'BUTTON', 5103050, 'platform:pallet:delete', '删除托盘', + NULL, NULL, 30, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0; + +-- 二期新增托盘权限默认只授予平台系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:pallet:list', 'platform:pallet:create', 'platform:pallet:edit', 'platform:pallet:delete' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- 兼容已经试运行过旧脚本的环境:移除人工启停权限。 +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id = rp.permission_id +WHERE p.tenant_id = 0 AND p.app_code = 'PLATFORM' AND p.permission_code = 'platform:pallet:status'; +UPDATE iam_permission +SET status = 'INACTIVE', deleted = 1 +WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code = 'platform:pallet:status'; + +-- ============================================================================ +-- 存量 File 导出中心安全升级 +-- ============================================================================ +USE baiyue_file; +DROP PROCEDURE IF EXISTS unified_file_add_column; +DELIMITER $$ +CREATE PROCEDURE unified_file_add_column( + IN p_column_name VARCHAR(64), IN p_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'file_export_task' + AND column_name = p_column_name + ) THEN + SET @unified_ddl = CONCAT( + 'ALTER TABLE file_export_task ADD COLUMN `', + REPLACE(p_column_name, '`', '``'), '` ', p_definition + ); + PREPARE unified_stmt FROM @unified_ddl; + EXECUTE unified_stmt; + DEALLOCATE PREPARE unified_stmt; + END IF; +END$$ +DELIMITER ; + +CALL unified_file_add_column('dispatch_version', + 'INT NOT NULL DEFAULT 1 COMMENT ''派发版本,仅执行重试时递增'' AFTER failure_message'); +CALL unified_file_add_column('attempt_count', + 'INT NOT NULL DEFAULT 0 COMMENT ''已执行尝试次数'' AFTER dispatch_version'); +CALL unified_file_add_column('max_attempts', + 'INT NOT NULL DEFAULT 3 COMMENT ''自动重试上限'' AFTER attempt_count'); +CALL unified_file_add_column('next_retry_at', + 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''允许执行的最早时间'' AFTER max_attempts'); +CALL unified_file_add_column('lease_owner', + 'VARCHAR(128) NULL COMMENT ''当前执行实例标识'' AFTER next_retry_at'); +CALL unified_file_add_column('lease_until', + 'DATETIME(3) NULL COMMENT ''执行租约截止时间'' AFTER lease_owner'); +CALL unified_file_add_column('last_heartbeat_at', + 'DATETIME(3) NULL COMMENT ''最后心跳时间'' AFTER lease_until'); +CALL unified_file_add_column('execution_deadline_at', + 'DATETIME(3) NULL COMMENT ''本次执行截止时间'' AFTER last_heartbeat_at'); +DROP PROCEDURE IF EXISTS unified_file_add_column; + +SET @unified_ddl = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'file_export_task' + AND index_name = 'idx_file_export_dispatch'), + 'SELECT 1', + 'ALTER TABLE file_export_task ADD INDEX idx_file_export_dispatch (status, next_retry_at, id)' +); +PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; + +SET @unified_ddl = IF( + EXISTS(SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'file_export_task' + AND index_name = 'idx_file_export_lease'), + 'SELECT 1', + 'ALTER TABLE file_export_task ADD INDEX idx_file_export_lease (status, lease_until)' +); +PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; + +-- ============================================================================ +-- 一期订单快照字段规格统一 +-- ============================================================================ +USE baiyue_order; +SET @unified_ddl = IF( + COALESCE((SELECT character_maximum_length FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ord_order' + AND column_name = 'receiving_warehouse_address'), 0) < 600, + 'ALTER TABLE ord_order MODIFY COLUMN receiving_warehouse_address VARCHAR(600) NULL COMMENT ''收货仓地址历史快照''', + 'SELECT 1' +); +PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; + +SET @unified_ddl = IF( + COALESCE((SELECT character_maximum_length FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ord_order' + AND column_name = 'receiving_time'), 0) < 128, + 'ALTER TABLE ord_order MODIFY COLUMN receiving_time VARCHAR(128) NULL COMMENT ''收货时间历史快照''', + 'SELECT 1' +); +PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; + +-- ============================================================================ +-- 重新同步一期商户管理员全局模板,包含一期后续追加的菜单和按钮 +-- ============================================================================ +USE baiyue_iam; +INSERT INTO iam_permission ( + id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, deleted +) +SELECT id + 100000, 0, app_code, permission_type, + CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, + permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 +FROM iam_permission +WHERE tenant_id = 10001 AND app_code = 'MERCHANT' + AND permission_type IN ('MENU', 'BUTTON') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + parent_id = VALUES(parent_id), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT owner_role.id, permission.id, 0 +FROM iam_role owner_role +JOIN iam_permission permission + ON permission.app_code = 'MERCHANT' AND permission.tenant_id = 0 + AND permission.permission_type IN ('MENU', 'BUTTON') + AND permission.status = 'ACTIVE' AND permission.deleted = 0 +WHERE owner_role.tenant_id = 0 AND owner_role.app_code = 'MERCHANT' + AND owner_role.role_code = 'MERCHANT_OWNER' + AND owner_role.status = 'ACTIVE' AND owner_role.deleted = 0 +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +-- ============================================================================ +-- 统一执行结果强校验:任一关键结构缺失立即终止,不输出虚假 PASS +-- ============================================================================ +DROP PROCEDURE IF EXISTS unified_assert_table; +DROP PROCEDURE IF EXISTS unified_assert_column; +DELIMITER $$ +CREATE PROCEDURE unified_assert_table(IN p_schema VARCHAR(64), IN p_table VARCHAR(64)) +BEGIN + DECLARE v_count INT DEFAULT 0; + DECLARE v_message VARCHAR(255); + SELECT COUNT(*) INTO v_count FROM information_schema.tables + WHERE table_schema = p_schema AND table_name = p_table; + IF v_count <> 1 THEN + SET v_message = CONCAT('统一迁移失败,缺少表:', p_schema, '.', p_table); + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = v_message; + END IF; +END$$ + +CREATE PROCEDURE unified_assert_column( + IN p_schema VARCHAR(64), IN p_table VARCHAR(64), IN p_column VARCHAR(64) +) +BEGIN + DECLARE v_count INT DEFAULT 0; + DECLARE v_message VARCHAR(255); + SELECT COUNT(*) INTO v_count FROM information_schema.columns + WHERE table_schema = p_schema AND table_name = p_table AND column_name = p_column; + IF v_count <> 1 THEN + SET v_message = CONCAT('统一迁移失败,缺少字段:', p_schema, '.', p_table, '.', p_column); + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = v_message; + END IF; +END$$ +DELIMITER ; + +CALL unified_assert_table('baiyue_auth', 'auth_account'); +CALL unified_assert_table('baiyue_iam', 'iam_permission'); +CALL unified_assert_table('baiyue_order', 'ord_order'); +CALL unified_assert_table('baiyue_warehouse', 'wh_warehouse'); +CALL unified_assert_table('baiyue_warehouse', 'wh_pallet'); +CALL unified_assert_table('baiyue_warehouse', 'wh_location'); +CALL unified_assert_table('baiyue_firstleg', 'fl_fulfillment_task'); +CALL unified_assert_table('baiyue_firstleg', 'fl_inbound_order'); +CALL unified_assert_table('baiyue_firstleg', 'fl_third_party_call_log'); +CALL unified_assert_table('baiyue_file', 'file_export_task_guard'); +CALL unified_assert_table('baiyue_file', 'file_export_outbox'); +CALL unified_assert_table('baiyue_audit', 'sys_audit_log'); +CALL unified_assert_table('baiyue_event_bridge', 'bridge_inbox'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); +CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at'); + +DROP PROCEDURE IF EXISTS unified_assert_table; +DROP PROCEDURE IF EXISTS unified_assert_column; + +SET @phase2_permission_count = ( + SELECT COUNT(DISTINCT permission_code) + FROM iam_permission + WHERE tenant_id = 0 AND deleted = 0 + AND permission_code IN ( + 'platform:firstleg:inbound:list', 'platform:firstleg:inbound:view', + 'platform:firstleg:inbound:measure:view', 'platform:firstleg:inbound:measure:edit', + 'platform:firstleg:inbound:measure:submit', 'platform:firstleg:inbound:weight:edit', + 'platform:firstleg:inbound:weight:confirm', 'firstleg:inbound:measurement-review', + 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release', + 'firstleg:inbound:send-fee', 'firstleg:fba-pricing:rule:view', + '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', + 'platform:location:create', 'platform:location:edit', 'platform:location:status', + 'platform:location:delete', 'platform:pallet:list', 'platform:pallet:create', + 'platform:pallet:edit', 'platform:pallet:delete' + ) +); + +DROP PROCEDURE IF EXISTS unified_assert_permission_count; +DELIMITER $$ +CREATE PROCEDURE unified_assert_permission_count() +BEGIN + IF @phase2_permission_count <> 28 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 28 项'; + END IF; +END$$ +DELIMITER ; +CALL unified_assert_permission_count(); +DROP PROCEDURE IF EXISTS unified_assert_permission_count; + +-- ============================================================================ +-- 2026-08-05:海外仓阶梯费用基础资料 CRUD 增量 +-- ============================================================================ +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule ( + id BIGINT UNSIGNED NOT NULL COMMENT '海外仓阶梯费用规则ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID,沿用tenant_id物理字段', + customer_name VARCHAR(128) NOT NULL COMMENT '客户公司名称快照', + currency CHAR(3) NOT NULL COMMENT 'ISO 4217币种三字码', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED 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 '乐观锁版本号', + active_customer_id BIGINT UNSIGNED + GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN tenant_id ELSE NULL END) STORED + COMMENT '未删除规则的客户唯一键', + PRIMARY KEY (id), + UNIQUE KEY uk_overseas_fee_active_customer (active_customer_id), + KEY idx_overseas_fee_page (deleted, updated_at, id), + KEY idx_overseas_fee_customer_name (customer_name, deleted) +) ENGINE=InnoDB COMMENT='客户维度海外仓阶梯仓储费规则'; + +CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier ( + id BIGINT UNSIGNED NOT NULL COMMENT '海外仓阶梯费用明细ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID,沿用tenant_id物理字段', + rule_id BIGINT UNSIGNED NOT NULL COMMENT '阶梯费用规则ID', + start_day SMALLINT UNSIGNED NOT NULL COMMENT '起始天,含边界', + end_day SMALLINT UNSIGNED NOT NULL COMMENT '结束天,含边界', + unit_price DECIMAL(18,6) NOT NULL COMMENT '仓储费标准', + sort_no SMALLINT UNSIGNED NOT NULL COMMENT '阶梯排序号', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED 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 '乐观锁版本号', + active_start_day SMALLINT UNSIGNED + GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN start_day ELSE NULL END) STORED + COMMENT '未删除阶梯的起始天唯一键', + PRIMARY KEY (id), + UNIQUE KEY uk_overseas_fee_tier_start (rule_id, active_start_day), + KEY idx_overseas_fee_tier_rule (tenant_id, rule_id, deleted, sort_no, id), + CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day >= 1 AND end_day >= start_day), + CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price > 0) +) ENGINE=InnoDB COMMENT='海外仓阶梯仓储费明细'; + +USE baiyue_iam; +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 + (5100030, 0, 'PLATFORM', 'MENU', 5100000, + 'platform:overseas-storage-fee:list', '海外仓阶梯费用', + '/customers/overseas-storage-fees', 'GET', 20, 'ACTIVE', 1, 0), + (5100031, 0, 'PLATFORM', 'BUTTON', 5100030, + 'platform:overseas-storage-fee:create', '新增海外仓阶梯费用', + NULL, NULL, 10, 'ACTIVE', 1, 0), + (5100032, 0, 'PLATFORM', 'BUTTON', 5100030, + 'platform:overseas-storage-fee:edit', '编辑海外仓阶梯费用', + NULL, NULL, 20, 'ACTIVE', 1, 0), + (5100033, 0, 'PLATFORM', 'BUTTON', 5100030, + 'platform:overseas-storage-fee:delete', '删除海外仓阶梯费用', + NULL, NULL, 30, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:overseas-storage-fee:list', + 'platform:overseas-storage-fee:create', + 'platform:overseas-storage-fee:edit', + 'platform:overseas-storage-fee:delete' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +DROP PROCEDURE IF EXISTS unified_assert_overseas_storage_fee; +DELIMITER $$ +CREATE PROCEDURE unified_assert_overseas_storage_fee() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'baiyue_warehouse' + AND table_name IN ('wh_overseas_storage_fee_rule', 'wh_overseas_storage_fee_tier')) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用表未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = 'baiyue_warehouse' + AND index_name IN ('uk_overseas_fee_active_customer', 'idx_overseas_fee_page', + 'idx_overseas_fee_customer_name', 'uk_overseas_fee_tier_start', + 'idx_overseas_fee_tier_rule')) <> 5 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用索引未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission + WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 + AND permission_code IN ( + 'platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create', + 'platform:overseas-storage-fee:edit', 'platform:overseas-storage-fee:delete')) <> 4 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用权限未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL unified_assert_overseas_storage_fee(); +DROP PROCEDURE IF EXISTS unified_assert_overseas_storage_fee; + +SELECT 'PASS' AS migration_status, + '统一增量执行完成,海外仓阶梯费用表、索引和权限校验通过' AS message; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java index ba4717409..84f9ee1ab 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java @@ -13,11 +13,8 @@ import java.security.MessageDigest; */ @Component public class InternalServiceTokenGuard { - private final String expectedToken; - - public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { - this.expectedToken = expectedToken; - } + @Value("${baiyue.security.internal-service-token:}") + private String expectedToken; public void verify(String actualToken) { if (expectedToken == null || expectedToken.isBlank()) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 8803d0a1a..119fa517b 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -1,5 +1,7 @@ package com.baiyue.eventbridge.service; +import jakarta.annotation.Resource; + import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -19,16 +21,12 @@ import java.time.LocalDateTime; */ @Service public class EventInboxService { - private final InboxStore inboxStore; - private final ObjectMapper objectMapper; - private final String localRegion; - - public EventInboxService(InboxStore inboxStore, ObjectMapper objectMapper, - @Value("${baiyue.region:CN}") String localRegion) { - this.inboxStore = inboxStore; - this.objectMapper = objectMapper; - this.localRegion = localRegion; - } + @Resource + private InboxStore inboxStore; + @Resource + private ObjectMapper objectMapper; + @Value("${baiyue.region:CN}") + private String localRegion; @Transactional public EventReceiveResult receive(EventEnvelope envelope) { diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index ec24e0714..834b47a8d 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -43,6 +43,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java index 9ad3b1ac5..5c70836e7 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java @@ -2,22 +2,34 @@ package com.baiyue.eventbridge.security; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @DisplayName("内部服务令牌校验") +@SpringJUnitConfig(InternalServiceTokenGuard.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token") class InternalServiceTokenGuardTest { + @Resource private InternalServiceTokenGuard guard; + + @AfterEach + void resetToken() { + ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token"); + } @Test void validTokenAllowsAccess() { - InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token"); assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException(); } @Test void unconfiguredTokenDisablesReceiveEndpoint() { - InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); + ReflectionTestUtils.setField(guard, "expectedToken", ""); assertThatThrownBy(() -> guard.verify("anything")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("接口已关闭"); @@ -25,7 +37,7 @@ class InternalServiceTokenGuardTest { @Test void invalidTokenIsRejected() { - InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected"); + ReflectionTestUtils.setField(guard, "expectedToken", "expected"); assertThatThrownBy(() -> guard.verify("wrong")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("令牌无效"); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index f2d21e484..4c55e03b0 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -6,9 +6,17 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.domain.BridgeInbox; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; @@ -22,10 +30,28 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("跨区域事件收件箱") +@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.region=OVERSEAS") class EventInboxServiceTest { - private final ObjectMapper objectMapper = new ObjectMapper(); - private final InboxStore store = mock(InboxStore.class); - private final EventInboxService service = new EventInboxService(store, objectMapper, "OVERSEAS"); + @Resource + private ObjectMapper objectMapper; + @MockBean + private InboxStore store; + @Resource + private EventInboxService service; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } + + @AfterEach + void resetRegion() { + ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS"); + } @Test void firstEventPersistsAndReturnsReceived() { @@ -66,10 +92,10 @@ class EventInboxServiceTest { @Test void targetRegionDifferentFromCurrentDeploymentIsRejected() { - EventInboxService cnService = new EventInboxService(store, objectMapper, "CN"); + ReflectionTestUtils.setField(service, "localRegion", "CN"); EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - assertThatThrownBy(() -> cnService.receive(envelope)) + assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("目标区域"); } diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java index eaf2752cf..3bd7ddf27 100644 --- a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java +++ b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java @@ -1,5 +1,8 @@ package com.baiyue.file.client; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; @@ -12,15 +15,17 @@ import java.util.concurrent.TimeUnit; @Component public class ExportProviderClient { - private final Map clients; - private final MeterRegistry meters; - - public ExportProviderClient(IamExportProviderClient iam, OrderExportProviderClient order, - WarehouseExportProviderClient warehouse, FirstlegExportProviderClient firstleg, - MeterRegistry meters) { + private Map clients; + @Resource private IamExportProviderClient iam; + @Resource private OrderExportProviderClient order; + @Resource private WarehouseExportProviderClient warehouse; + @Resource private FirstlegExportProviderClient firstleg; + @Resource + private MeterRegistry meters; + @PostConstruct + void initializeClients() { this.clients = Map.of("iam-service", iam, "order-service", order, "warehouse-service", warehouse, "firstleg-service", firstleg); - this.meters = meters; } public ApiResponse batch(String internalToken, String exportType, diff --git a/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java index 31ac02e52..e8f41469e 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalMeasurementImageController.java @@ -1,5 +1,8 @@ package com.baiyue.file.controller; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -27,14 +30,15 @@ import org.springframework.http.ResponseEntity; @RestController @RequestMapping("/internal/file/v1/measurement-images") public class InternalMeasurementImageController { - private final MeasurementImageService images; - private final byte[] expectedToken; + @Resource + private MeasurementImageService images; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; - public InternalMeasurementImageController( - MeasurementImageService images, - @Value("${baiyue.security.internal-service-token}") String token) { - this.images = images; - this.expectedToken = token.getBytes(StandardCharsets.UTF_8); + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java index 21d95baf2..fd0c60fea 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java @@ -1,5 +1,7 @@ package com.baiyue.file.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; @@ -27,9 +29,8 @@ import java.time.LocalDate; @RequestMapping("/api/platform/v1/files/export-tasks") @Validated public class PlatformExportTaskController { - private final ExportTaskService service; - - public PlatformExportTaskController(ExportTaskService service) { this.service = service; } + @Resource + private ExportTaskService service; @PostMapping @PreAuthorize("isAuthenticated()") diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java index 54988ec9b..8a549a77c 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java @@ -1,5 +1,7 @@ package com.baiyue.file.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -29,11 +31,8 @@ import java.nio.charset.StandardCharsets; @RequestMapping("/api/platform/v1/files/measurements") @Tag(name = "平台入库测量图片") public class PlatformMeasurementImageController { - private final MeasurementImageService images; - - public PlatformMeasurementImageController(MeasurementImageService images) { - this.images = images; - } + @Resource + private MeasurementImageService images; @PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')") diff --git a/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java b/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java index 315ef023f..120f0621e 100644 --- a/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java +++ b/file/src/main/java/com/baiyue/file/mq/ExportCommandConsumer.java @@ -1,5 +1,8 @@ package com.baiyue.file.mq; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.export.ExportContracts.ExportCommand; import com.baiyue.file.service.ExportTaskExecutor; import com.fasterxml.jackson.databind.ObjectMapper; @@ -21,20 +24,18 @@ import java.nio.charset.StandardCharsets; selectorExpression="EXPORT_TASK", messageModel= MessageModel.CLUSTERING, consumeThreadNumber=4, consumeThreadMax=4, consumeTimeout=90, maxReconsumeTimes=2) public class ExportCommandConsumer implements RocketMQListener, RocketMQPushConsumerLifecycleListener { - private final ObjectMapper objectMapper; - private final ExportTaskExecutor executor; - private final String instance; - private final int concurrency; - private final long consumeTimeoutMinutes; - private final int maxReconsumeTimes; - public ExportCommandConsumer(ObjectMapper objectMapper, ExportTaskExecutor executor, - @Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") String instance, - @Value("${baiyue.file.export.consumer.concurrency:4}") int concurrency, - @Value("${baiyue.file.export.consumer.consume-timeout-minutes:90}") long consumeTimeoutMinutes, - @Value("${baiyue.file.export.consumer.max-reconsume-times:2}") int maxReconsumeTimes) { - this.objectMapper=objectMapper;this.executor=executor;this.instance=instance; + @Resource + private ObjectMapper objectMapper; + @Resource + private ExportTaskExecutor executor; + @Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") private String instance; + @Value("${baiyue.file.export.consumer.concurrency:4}") private int concurrency; + @Value("${baiyue.file.export.consumer.consume-timeout-minutes:90}") private long consumeTimeoutMinutes; + @Value("${baiyue.file.export.consumer.max-reconsume-times:2}") private int maxReconsumeTimes; + + @PostConstruct + void validateConfiguration() { if (concurrency < 1 || concurrency > 64) throw new IllegalArgumentException("Export consumer concurrency must be between 1 and 64"); - this.concurrency=concurrency;this.consumeTimeoutMinutes=consumeTimeoutMinutes;this.maxReconsumeTimes=maxReconsumeTimes; } @Override public void prepareStart(DefaultMQPushConsumer consumer) { consumer.setConsumeThreadMin(concurrency); diff --git a/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java b/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java index c5a3bfbc5..6d6bf06a7 100644 --- a/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java +++ b/file/src/main/java/com/baiyue/file/mq/ExportDlqHandler.java @@ -1,5 +1,7 @@ package com.baiyue.file.mq; +import jakarta.annotation.Resource; + import com.baiyue.file.service.ExportStateService; import io.micrometer.core.instrument.MeterRegistry; import org.apache.rocketmq.common.message.MessageExt; @@ -18,9 +20,10 @@ import org.springframework.stereotype.Service; consumeThreadNumber=1, consumeThreadMax=1, maxReconsumeTimes=Integer.MAX_VALUE) public class ExportDlqHandler implements RocketMQListener { private static final Logger LOGGER = LoggerFactory.getLogger(ExportDlqHandler.class); - private final ExportStateService states; - private final MeterRegistry meters; - public ExportDlqHandler(ExportStateService states, MeterRegistry meters) { this.states=states; this.meters=meters; } + @Resource + private ExportStateService states; + @Resource + private MeterRegistry meters; @Override public void onMessage(MessageExt message) { meters.counter("export_dlq_messages").increment(); String eventId=message.getUserProperty("eventId"), taskId=message.getUserProperty("taskId"); diff --git a/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java b/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java index 1a86faea3..f0aaf0163 100644 --- a/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java +++ b/file/src/main/java/com/baiyue/file/mq/ExportOutboxPublisher.java @@ -1,5 +1,7 @@ package com.baiyue.file.mq; +import jakarta.annotation.Resource; + import com.baiyue.file.entity.FileExportOutbox; import com.baiyue.file.repository.ExportOutboxRepository; import org.apache.rocketmq.spring.core.RocketMQTemplate; @@ -17,17 +19,13 @@ import org.springframework.stereotype.Service; public class ExportOutboxPublisher { private static final Logger LOGGER = LoggerFactory.getLogger(ExportOutboxPublisher.class); private static final int[] BACKOFF = {5, 15, 60, 300, 900}; - private final ExportOutboxRepository outbox; - private final RocketMQTemplate rocketMQ; - private final String owner; - private final int batchSize; - private final int leaseSeconds; - public ExportOutboxPublisher(ExportOutboxRepository outbox, RocketMQTemplate rocketMQ, - @Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") String owner, - @Value("${baiyue.file.export.publisher.batch-size:50}") int batchSize, - @Value("${baiyue.file.export.publisher.lease-seconds:60}") int leaseSeconds) { - this.outbox=outbox;this.rocketMQ=rocketMQ;this.owner=owner;this.batchSize=batchSize;this.leaseSeconds=leaseSeconds; - } + @Resource + private ExportOutboxRepository outbox; + @Resource + private RocketMQTemplate rocketMQ; + @Value("${baiyue.file.export.instance-id:${HOSTNAME:${random.uuid}}}") private String owner; + @Value("${baiyue.file.export.publisher.batch-size:50}") private int batchSize; + @Value("${baiyue.file.export.publisher.lease-seconds:60}") private int leaseSeconds; @Scheduled(fixedDelayString="${baiyue.file.export.publisher.poll-interval-ms:5000}") public void publish() { for (Long id : outbox.publishableIds(batchSize)) { diff --git a/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java index e731fdfd3..9eac22349 100644 --- a/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java +++ b/file/src/main/java/com/baiyue/file/repository/ExportOutboxRepository.java @@ -1,5 +1,7 @@ package com.baiyue.file.repository; +import jakarta.annotation.Resource; + import com.baiyue.file.entity.FileExportOutbox; import com.baiyue.file.mapper.FileExportOutboxMapper; import org.springframework.dao.DuplicateKeyException; @@ -9,8 +11,8 @@ import java.util.List; @Repository public class ExportOutboxRepository { - private final FileExportOutboxMapper mapper; - public ExportOutboxRepository(FileExportOutboxMapper mapper) { this.mapper = mapper; } + @Resource + private FileExportOutboxMapper mapper; public boolean insert(FileExportOutbox row) { try { return mapper.insertCommand(row) == 1; } catch (DuplicateKeyException ignored) { return false; } diff --git a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java index dcec05c84..be4797a05 100644 --- a/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java +++ b/file/src/main/java/com/baiyue/file/repository/ExportTaskRepository.java @@ -1,5 +1,7 @@ package com.baiyue.file.repository; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.PageResult; import com.baiyue.file.entity.FileExportTask; import com.baiyue.file.mapper.FileExportTaskMapper; @@ -16,11 +18,8 @@ import java.util.List; @Repository public class ExportTaskRepository { - private final FileExportTaskMapper mapper; - - public ExportTaskRepository(FileExportTaskMapper mapper) { - this.mapper = mapper; - } + @Resource + private FileExportTaskMapper mapper; @PostConstruct void validateCreationGuard() { diff --git a/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java b/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java index f0e7ae1d7..c3577a034 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java +++ b/file/src/main/java/com/baiyue/file/service/ExportCommandFactory.java @@ -1,5 +1,7 @@ package com.baiyue.file.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.export.ExportContracts.ExportCommand; import com.baiyue.file.entity.FileExportOutbox; import com.baiyue.file.entity.FileExportTask; @@ -15,13 +17,10 @@ import java.util.UUID; @Component public class ExportCommandFactory { - private final ObjectMapper objectMapper; - private final String topic; - public ExportCommandFactory(ObjectMapper objectMapper, - @Value("${baiyue.file.export.rocketmq.topic:test-baiyue-file-export-command-v1}") String topic) { - this.objectMapper = objectMapper; - this.topic = topic; - } + @Resource + private ObjectMapper objectMapper; + @Value("${baiyue.file.export.rocketmq.topic:test-baiyue-file-export-command-v1}") + private String topic; public FileExportOutbox create(FileExportTask task, int dispatchVersion) { String eventId = UUID.randomUUID().toString(); ExportCommand command = new ExportCommand(1, eventId, String.valueOf(task.getId()), task.getExportType(), diff --git a/file/src/main/java/com/baiyue/file/service/ExportMetrics.java b/file/src/main/java/com/baiyue/file/service/ExportMetrics.java index 4c6954d84..740e69bf3 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportMetrics.java +++ b/file/src/main/java/com/baiyue/file/service/ExportMetrics.java @@ -1,5 +1,8 @@ package com.baiyue.file.service; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.jdbc.core.JdbcTemplate; @@ -7,9 +10,11 @@ import org.springframework.stereotype.Component; @Component public class ExportMetrics { - private final MeterRegistry registry; - public ExportMetrics(MeterRegistry registry, JdbcTemplate jdbc) { - this.registry=registry; + @Resource private MeterRegistry registry; + @Resource private JdbcTemplate jdbc; + + @PostConstruct + void registerGauges() { Gauge.builder("export_task_queue_age_seconds",jdbc,j->number(j,"SELECT COALESCE(TIMESTAMPDIFF(SECOND,MIN(requested_at),CURRENT_TIMESTAMP(3)),0) FROM file_export_task WHERE status='PENDING'")).register(registry); Gauge.builder("export_outbox_pending",jdbc,j->number(j,"SELECT COUNT(*) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry); Gauge.builder("export_outbox_oldest_age_seconds",jdbc,j->number(j,"SELECT COALESCE(TIMESTAMPDIFF(SECOND,MIN(created_at),CURRENT_TIMESTAMP(3)),0) FROM file_export_outbox WHERE status IN ('PENDING','CLAIMED')")).register(registry); diff --git a/file/src/main/java/com/baiyue/file/service/ExportReconciler.java b/file/src/main/java/com/baiyue/file/service/ExportReconciler.java index 15bc6000b..f072ac66a 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportReconciler.java +++ b/file/src/main/java/com/baiyue/file/service/ExportReconciler.java @@ -1,5 +1,7 @@ package com.baiyue.file.service; +import jakarta.annotation.Resource; + import com.baiyue.file.entity.FileExportTask; import com.baiyue.file.repository.ExportOutboxRepository; import com.baiyue.file.repository.ExportTaskRepository; @@ -16,18 +18,14 @@ import java.time.Duration; @ConditionalOnProperty(prefix="baiyue.file.export.reconciler", name="enabled", havingValue="true") public class ExportReconciler { private static final Logger LOGGER= LoggerFactory.getLogger(ExportReconciler.class); - private final ExportTaskRepository tasks; private final ExportOutboxRepository outbox; - private final ExportStateService states; private final ExportStorage storage; private final MeterRegistry meters; - private final int graceSeconds, sentStaleSeconds; - private final int retentionDays; - public ExportReconciler(ExportTaskRepository tasks, ExportOutboxRepository outbox, ExportStateService states, - ExportStorage storage, MeterRegistry meters, - @Value("${baiyue.file.export.reconciler.redispatch-grace-seconds:60}") int graceSeconds, - @Value("${baiyue.file.export.reconciler.sent-stale-seconds:600}") int sentStaleSeconds, - @Value("${baiyue.file.export.retention-days:7}") int retentionDays) { - this.tasks=tasks;this.outbox=outbox;this.states=states;this.storage=storage;this.meters=meters; - this.graceSeconds=graceSeconds;this.sentStaleSeconds=sentStaleSeconds;this.retentionDays=retentionDays; - } + @Resource private ExportTaskRepository tasks; + @Resource private ExportOutboxRepository outbox; + @Resource private ExportStateService states; + @Resource private ExportStorage storage; + @Resource private MeterRegistry meters; + @Value("${baiyue.file.export.reconciler.redispatch-grace-seconds:60}") private int graceSeconds; + @Value("${baiyue.file.export.reconciler.sent-stale-seconds:600}") private int sentStaleSeconds; + @Value("${baiyue.file.export.retention-days:7}") private int retentionDays; @Scheduled(cron="${baiyue.file.export.reconciler.outbox-cleanup-cron:0 15 3 * * *}") public void cleanupRetainedOutbox() { count("outbox_clean",outbox.deleteRetainedSent(retentionDays,5000)); diff --git a/file/src/main/java/com/baiyue/file/service/ExportStateService.java b/file/src/main/java/com/baiyue/file/service/ExportStateService.java index 8f3c288e8..d03ed3856 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportStateService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportStateService.java @@ -1,5 +1,7 @@ package com.baiyue.file.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.file.entity.FileExportTask; @@ -11,14 +13,12 @@ import org.springframework.transaction.annotation.Transactional; @Service public class ExportStateService { - private final ExportTaskRepository tasks; - private final ExportOutboxRepository outbox; - private final ExportCommandFactory commands; - private final ExportMetrics metrics; - public ExportStateService(ExportTaskRepository tasks, ExportOutboxRepository outbox, ExportCommandFactory commands, - ExportMetrics metrics) { - this.tasks = tasks; this.outbox = outbox; this.commands = commands; this.metrics = metrics; - } + @Resource + private ExportTaskRepository tasks; + @Resource + private ExportOutboxRepository outbox; + @Resource private ExportCommandFactory commands; + @Resource private ExportMetrics metrics; @Transactional public CreationResult createConstrained(FileExportTask task) { tasks.lockCreationGuard(); diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java b/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java index 5790dec11..78baac807 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskExecutor.java @@ -1,5 +1,7 @@ package com.baiyue.file.service; +import jakarta.annotation.Resource; + import com.baiyue.file.config.ExportDefinitionRegistry; import com.baiyue.file.entity.FileExportTask; import com.baiyue.file.repository.ExportTaskRepository; @@ -16,27 +18,22 @@ import java.util.concurrent.atomic.AtomicBoolean; @Service public class ExportTaskExecutor { private static final Logger LOGGER = LoggerFactory.getLogger(ExportTaskExecutor.class); - private final ExportTaskRepository tasks; - private final ExportDefinitionRegistry definitions; - private final GenericXlsxExportRunner runner; - private final ExportStateService states; - private final ExportStorage storage; - private final MeterRegistry meters; - private final int leaseSeconds, heartbeatSeconds, maxDurationMinutes, retentionDays; + @Resource + private ExportTaskRepository tasks; + @Resource + private ExportDefinitionRegistry definitions; + @Resource private GenericXlsxExportRunner runner; + @Resource private ExportStateService states; + @Resource private ExportStorage storage; + @Resource private MeterRegistry meters; + @Value("${baiyue.file.export.task.lease-seconds:300}") private int leaseSeconds; + @Value("${baiyue.file.export.task.heartbeat-seconds:60}") private int heartbeatSeconds; + @Value("${baiyue.file.export.task.max-duration-minutes:30}") private int maxDurationMinutes; + @Value("${baiyue.file.export.retention-days:7}") private int retentionDays; private final ScheduledExecutorService watchdogs = Executors.newScheduledThreadPool(4, runnable -> { Thread thread = new Thread(runnable, "file-export-watchdog"); thread.setDaemon(true); return thread; }); - public ExportTaskExecutor(ExportTaskRepository tasks, ExportDefinitionRegistry definitions, - GenericXlsxExportRunner runner, ExportStateService states, ExportStorage storage, MeterRegistry meters, - @Value("${baiyue.file.export.task.lease-seconds:300}") int leaseSeconds, - @Value("${baiyue.file.export.task.heartbeat-seconds:60}") int heartbeatSeconds, - @Value("${baiyue.file.export.task.max-duration-minutes:30}") int maxDurationMinutes, - @Value("${baiyue.file.export.retention-days:7}") int retentionDays) { - this.tasks=tasks;this.definitions=definitions;this.runner=runner;this.states=states;this.storage=storage;this.meters=meters; - this.leaseSeconds=leaseSeconds;this.heartbeatSeconds=heartbeatSeconds;this.maxDurationMinutes=maxDurationMinutes;this.retentionDays=retentionDays; - } - public void execute(long taskId, int dispatchVersion, String owner) { if (!tasks.claim(taskId, dispatchVersion, owner, leaseSeconds, maxDurationMinutes)) return; FileExportTask task = tasks.find(taskId); diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index ffa345884..578bf3462 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -1,5 +1,7 @@ package com.baiyue.file.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -22,23 +24,12 @@ import java.util.TreeMap; @Service public class ExportTaskService { - private final ExportTaskRepository repository; - private final ExportStorage storage; - private final ObjectMapper objectMapper; - private final ExportDefinitionRegistry registry; - private final ExportStateService stateService; - private final ExportMetrics metrics; - - public ExportTaskService(ExportTaskRepository repository, ExportStorage storage, - ObjectMapper objectMapper, ExportDefinitionRegistry registry, - ExportStateService stateService, ExportMetrics metrics) { - this.repository = repository; - this.storage = storage; - this.objectMapper = objectMapper; - this.registry = registry; - this.stateService = stateService; - this.metrics = metrics; - } + @Resource private ExportTaskRepository repository; + @Resource private ExportStorage storage; + @Resource private ObjectMapper objectMapper; + @Resource private ExportDefinitionRegistry registry; + @Resource private ExportStateService stateService; + @Resource private ExportMetrics metrics; public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) { requirePlatform(user); diff --git a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java index b41e9eb63..e65f575fb 100644 --- a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java +++ b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java @@ -1,5 +1,8 @@ package com.baiyue.file.service; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; @@ -31,17 +34,16 @@ public class GenericXlsxExportRunner { private static final Logger LOGGER = LoggerFactory.getLogger(GenericXlsxExportRunner.class); public static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final int EXCEL_SHEET_ROWS = 1_048_575; - private final ExportProviderClient provider; - private final ExportStorage storage; - private final ObjectMapper objectMapper; - private final String internalToken; - private final long maxFileBytes; + @Resource private ExportProviderClient provider; + @Resource private ExportStorage storage; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.file.export.task.max-file-size-mb:50}") private long maxFileSizeMb; + private long maxFileBytes; - public GenericXlsxExportRunner(ExportProviderClient provider, ExportStorage storage, ObjectMapper objectMapper, - @Value("${baiyue.security.internal-service-token}") String internalToken, - @Value("${baiyue.file.export.task.max-file-size-mb:50}") long maxFileSizeMb) { - this.provider = provider; this.storage = storage; this.objectMapper = objectMapper; - this.internalToken = internalToken; this.maxFileBytes = Math.max(1, maxFileSizeMb) * 1024L * 1024L; + @PostConstruct + void initializeLimits() { + maxFileBytes = Math.max(1, maxFileSizeMb) * 1024L * 1024L; } public Result run(FileExportTask task, Definition definition, AtomicBoolean abort, LongConsumer progress) { diff --git a/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java index da34ac5a6..b9bd80214 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalExportStorage.java @@ -21,16 +21,14 @@ import java.util.UUID; @Service public class LocalExportStorage implements ExportStorage { - private final Path root; - private final Path exportRoot; - - public LocalExportStorage(@Value("${baiyue.file.storage-root}") String root) { - this.root = Path.of(root).toAbsolutePath().normalize(); - this.exportRoot = this.root.resolve("exports").normalize(); - } + @Value("${baiyue.file.storage-root}") private String configuredRoot; + private Path root; + private Path exportRoot; @PostConstruct void initialize() { + root = Path.of(configuredRoot).toAbsolutePath().normalize(); + exportRoot = root.resolve("exports").normalize(); try { Files.createDirectories(exportRoot); if (!exportRoot.startsWith(root) || !Files.isDirectory(exportRoot) || !Files.isWritable(exportRoot)) { diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index c25721192..fecb01c5a 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -26,17 +26,15 @@ public class LocalQualificationStorage { private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class); private static final long MAX_SIZE = 5L * 1024 * 1024; private static final Set TYPES = Set.of("image/jpeg", "image/png", "application/pdf"); - private final Path root; - private final Path qualificationRoot; - - public LocalQualificationStorage(@Value("${baiyue.file.storage-root}") String root) { - this.root = Path.of(root).toAbsolutePath().normalize(); - this.qualificationRoot = this.root.resolve("qualifications").normalize(); - } + @Value("${baiyue.file.storage-root}") private String configuredRoot; + private Path root; + private Path qualificationRoot; /** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */ @PostConstruct public void initialize() { + root = Path.of(configuredRoot).toAbsolutePath().normalize(); + qualificationRoot = root.resolve("qualifications").normalize(); try { Files.createDirectories(root); Files.createDirectories(qualificationRoot); diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java index 66b634e20..56608aa6e 100644 --- a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java @@ -1,5 +1,7 @@ package com.baiyue.file.service; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -20,19 +22,10 @@ import java.time.LocalDateTime; @Service public class MeasurementImageService { - private final MeasurementImageStorage storage; - private final FileObjectMapper files; - private final WeightOcrClient ocr; - private final String internalToken; - - public MeasurementImageService(MeasurementImageStorage storage, FileObjectMapper files, - WeightOcrClient ocr, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.storage = storage; - this.files = files; - this.ocr = ocr; - this.internalToken = internalToken; - } + @Resource private MeasurementImageStorage storage; + @Resource private FileObjectMapper files; + @Resource private WeightOcrClient ocr; + @Value("${baiyue.security.internal-service-token}") private String internalToken; @Transactional public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) { diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java index 7dfc1cf09..92ac9620b 100644 --- a/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageStorage.java @@ -2,6 +2,7 @@ package com.baiyue.file.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -22,11 +23,11 @@ import java.util.UUID; public class MeasurementImageStorage { private static final long MAX_SIZE = 5L * 1024 * 1024; private static final Set TYPES = Set.of("image/jpeg", "image/png", "image/bmp"); - private final Path root; + @Value("${baiyue.file.storage-root}") private Path configuredRoot; + private Path root; - public MeasurementImageStorage(@Value("${baiyue.file.storage-root}") Path root) { - this.root = root.toAbsolutePath().normalize(); - } + @PostConstruct + void initializeRoot() { root = configuredRoot.toAbsolutePath().normalize(); } public Stored store(long tenantId, long inboundId, MultipartFile file) { if (file == null || file.isEmpty()) throw bad("称重图片不能为空"); diff --git a/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java b/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java index 0b07bf0e3..76b96daed 100644 --- a/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/QualificationFileStorage.java @@ -1,6 +1,7 @@ package com.baiyue.file.service; import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -18,11 +19,11 @@ import java.util.UUID; @Service public class QualificationFileStorage { private static final Set CONTENT_TYPES = Set.of("image/jpeg", "image/png"); - private final Path root; + @Value("${baiyue.file.storage-root}") private String configuredRoot; + private Path root; - public QualificationFileStorage(@Value("${baiyue.file.storage-root}") String root) { - this.root = Path.of(root).toAbsolutePath().normalize().resolve("qualifications"); - } + @PostConstruct + void initializeRoot() { root = Path.of(configuredRoot).toAbsolutePath().normalize().resolve("qualifications"); } public StoredFile store(long tenantId, MultipartFile file) { if (file == null || file.isEmpty()) throw new BizException("One qualification image is required"); diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 493aae451..88f3791af 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -103,6 +103,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java index 01cb5a1a9..0fa8ba91a 100644 --- a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java @@ -5,26 +5,42 @@ import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; import com.baiyue.file.config.ExportDefinitionRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.*; +@SpringJUnitConfig(classes = {ExportProviderClient.class, ExportProviderClientTest.TestConfig.class}) class ExportProviderClientTest { + @MockBean private IamExportProviderClient iam; + @MockBean private OrderExportProviderClient order; + @MockBean private WarehouseExportProviderClient warehouse; + @MockBean private FirstlegExportProviderClient firstleg; + @Resource private ExportProviderClient client; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean + MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + } + @Test void routesTheRequestToTheAllowlistedServiceSelectedByTheDefinition() { - IamExportProviderClient iam=mock(IamExportProviderClient.class); - OrderExportProviderClient order=mock(OrderExportProviderClient.class); - WarehouseExportProviderClient warehouse=mock(WarehouseExportProviderClient.class); - FirstlegExportProviderClient firstleg=mock(FirstlegExportProviderClient.class); var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L)); when(order.batch(anyString(),anyString(),any())).thenReturn(response); var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("order-service"); ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500); - var actual=new ExportProviderClient(iam,order,warehouse,firstleg,new SimpleMeterRegistry()) - .batch("token","ORDER_LIST",definition,request); + var actual=client.batch("token","ORDER_LIST",definition,request); assertSame(response,actual); verify(order).batch("token","ORDER_LIST",request); diff --git a/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java index 0a2de2ea4..53f081a3c 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalMeasurementImageControllerTest.java @@ -2,9 +2,13 @@ package com.baiyue.file.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.file.service.MeasurementImageService; +import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -12,13 +16,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(InternalMeasurementImageController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalMeasurementImageControllerTest { - private final MeasurementImageService images = mock(MeasurementImageService.class); - private final InternalMeasurementImageController controller = - new InternalMeasurementImageController(images, "internal-token"); + @MockBean + private MeasurementImageService images; + @Resource + private InternalMeasurementImageController controller; @Test - void 保存第三方尺寸测量图片但不触发重量识别契约() { + void shouldHandleCase01() { var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2, 3}); var result = new MeasurementImageService.UploadResult(41L, "measure.png", @@ -33,7 +40,7 @@ class InternalMeasurementImageControllerTest { } @Test - void 内部令牌错误时拒绝保存() { + void shouldHandleCase02() { var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1}); assertThatThrownBy(() -> controller.upload("bad-token", 31L, 11L, 21L, file)) @@ -41,7 +48,7 @@ class InternalMeasurementImageControllerTest { .hasMessageContaining("凭证无效"); } @Test - void 内部下载同时校验租户入库单和文件ID() { + void shouldHandleCase03() { when(images.download(11L, 21L, 41L)).thenReturn( new MeasurementImageService.Download("measure.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2, 3})); diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java index a4233746d..ef7d04f7a 100644 --- a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java @@ -3,12 +3,15 @@ package com.baiyue.file.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.file.service.MeasurementImageService; +import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.lang.reflect.Method; import java.util.Set; @@ -18,13 +21,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +@SpringJUnitConfig(PlatformMeasurementImageController.class) class PlatformMeasurementImageControllerContractTest { - private final MeasurementImageService images = mock(MeasurementImageService.class); - private final PlatformMeasurementImageController controller = - new PlatformMeasurementImageController(images); + @MockBean + private MeasurementImageService images; + @Resource + private PlatformMeasurementImageController controller; @Test - void 暴露平台测量图片multipart入口并校验重量维护权限() throws Exception { + void shouldHandleCase01() throws Exception { RequestMapping base = PlatformMeasurementImageController.class.getAnnotation(RequestMapping.class); assertThat(base.value()).containsExactly("/api/platform/v1/files/measurements"); Method method = PlatformMeasurementImageController.class.getDeclaredMethod("upload", @@ -36,7 +41,7 @@ class PlatformMeasurementImageControllerContractTest { } @Test - void 商户身份不能调用平台上传接口() { + void shouldHandleCase02() { var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1}); var merchant = new AuthenticatedUser(2L, 10001L, 10001L, 2L, "merchant", "MERCHANT", "WEB", Set.of()); @@ -46,7 +51,7 @@ class PlatformMeasurementImageControllerContractTest { } @Test - void 下载始终同时使用租户入库单和文件ID隔离() { + void shouldHandleCase03() { var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); org.mockito.Mockito.when(images.download(10001L, 88L, 99L)).thenReturn( diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 51687cf81..caf4ed36d 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -6,12 +6,16 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.service.LocalQualificationStorage; +import com.baiyue.file.service.QualificationPackageService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; 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.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -20,6 +24,8 @@ import java.time.LocalDate; import java.util.Arrays; import java.util.Set; +import jakarta.annotation.Resource; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -32,7 +38,14 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("企业资质文件接口契约功能") +@SpringJUnitConfig(classes = {QualificationFileController.class, PlatformQualificationFileController.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class QualificationFileContractTest { + @MockBean private LocalQualificationStorage defaultStorage; + @MockBean private IamQualificationClient defaultIamClient; + @MockBean private QualificationPackageService packageService; + @Resource private PlatformQualificationFileController platformController; + @Resource private QualificationFileController merchantController; @Test void fileServiceExposesTheOnlyPublicMultipartUploadEntry() { @@ -246,12 +259,11 @@ class QualificationFileContractTest { } } - private static PlatformQualificationFileController platformController( + private PlatformQualificationFileController platformController( LocalQualificationStorage storage, IamQualificationClient iamClient) { - var controller = new PlatformQualificationFileController(); + var controller = platformController; ReflectionTestUtils.setField(controller, "storage", storage); ReflectionTestUtils.setField(controller, "iamClient", iamClient); - ReflectionTestUtils.setField(controller, "internalToken", "internal-token"); return controller; } @@ -263,12 +275,11 @@ class QualificationFileContractTest { && Arrays.asList(mapping.consumes()).contains(MediaType.MULTIPART_FORM_DATA_VALUE)); } - private static QualificationFileController merchantController( + private QualificationFileController merchantController( LocalQualificationStorage storage, IamQualificationClient iamClient) { - var controller = new QualificationFileController(); + var controller = merchantController; ReflectionTestUtils.setField(controller, "storage", storage); ReflectionTestUtils.setField(controller, "iamClient", iamClient); - ReflectionTestUtils.setField(controller, "internalToken", "internal-token"); return controller; } diff --git a/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java b/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java index ae87ae9a6..80c82a301 100644 --- a/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java +++ b/file/src/test/java/com/baiyue/file/mq/ExportCommandConsumerTest.java @@ -2,9 +2,15 @@ package com.baiyue.file.mq; import com.baiyue.file.service.ExportTaskExecutor; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; import org.apache.rocketmq.common.message.MessageExt; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.nio.charset.StandardCharsets; @@ -13,10 +19,24 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +@SpringJUnitConfig(classes = {ExportCommandConsumer.class, ExportCommandConsumerTest.TestConfig.class}) +@TestPropertySource(properties = { + "baiyue.file.export.consumer.enabled=true", + "baiyue.file.export.instance-id=file-1", + "baiyue.file.export.consumer.concurrency=4", + "baiyue.file.export.consumer.consume-timeout-minutes=90", + "baiyue.file.export.consumer.max-reconsume-times=2" +}) class ExportCommandConsumerTest { + @MockBean private ExportTaskExecutor executor; + @Resource private ExportCommandConsumer consumer; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } + @Test void appliesConfiguredConcurrencyAndDispatchesAValidCommand() { - ExportTaskExecutor executor=mock(ExportTaskExecutor.class); - ExportCommandConsumer consumer=new ExportCommandConsumer(new ObjectMapper(),executor,"file-1",4,90,2); DefaultMQPushConsumer pushConsumer=new DefaultMQPushConsumer(); consumer.prepareStart(pushConsumer); assertEquals(4,pushConsumer.getConsumeThreadMin()); @@ -31,7 +51,6 @@ class ExportCommandConsumerTest { } @Test void rejectsMalformedMessagesSoRocketMqCanRedeliverThem() { - ExportCommandConsumer consumer=new ExportCommandConsumer(new ObjectMapper(),mock(ExportTaskExecutor.class),"file-1",4,90,2); MessageExt message=new MessageExt();message.setBody("not-json".getBytes(StandardCharsets.UTF_8)); assertThrows(IllegalArgumentException.class,() -> consumer.onMessage(message)); } diff --git a/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java b/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java index 573bd7103..4134adc1a 100644 --- a/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java +++ b/file/src/test/java/com/baiyue/file/mq/ExportDlqHandlerTest.java @@ -1,29 +1,46 @@ package com.baiyue.file.mq; import com.baiyue.file.service.ExportStateService; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.Resource; import org.apache.rocketmq.common.message.MessageExt; import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; import org.junit.jupiter.api.Test; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.mockito.Mockito.*; +@SpringJUnitConfig(classes = {ExportDlqHandler.class, ExportDlqHandlerTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.file.export.consumer.enabled=true") class ExportDlqHandlerTest { + @MockBean private ExportStateService states; + @Resource private ExportDlqHandler handler; + @Resource private MeterRegistry meters; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean MeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } + } @Test void databaseFailuresCannotFallIntoAnUnconsumedSecondaryDlq() { RocketMQMessageListener listener=ExportDlqHandler.class.getAnnotation(RocketMQMessageListener.class); org.junit.jupiter.api.Assertions.assertEquals(Integer.MAX_VALUE,listener.maxReconsumeTimes()); } @Test void quarantinesUsingUserPropertiesWithoutParsingTheBody() { - ExportStateService states=mock(ExportStateService.class); MessageExt message=new MessageExt(); - SimpleMeterRegistry meters=new SimpleMeterRegistry(); + MessageExt message=new MessageExt(); message.putUserProperty("eventId","event-1");message.putUserProperty("taskId","9");message.putUserProperty("dispatchVersion","3");message.putUserProperty("schemaVersion","1"); - new ExportDlqHandler(states,meters).onMessage(message); + handler.onMessage(message); verify(states).quarantine(eq("event-1"),eq(9L),eq(3),contains("自动隔离")); org.junit.jupiter.api.Assertions.assertEquals(1,meters.counter("export_dlq_messages").count()); } @Test void malformedPropertiesAreAcknowledgedWithoutTouchingTasks() { - ExportStateService states=mock(ExportStateService.class); SimpleMeterRegistry meters=new SimpleMeterRegistry(); - new ExportDlqHandler(states,meters).onMessage(new MessageExt()); - verifyNoInteractions(states); org.junit.jupiter.api.Assertions.assertEquals(1,meters.counter("export_dlq_messages").count()); + double before = meters.counter("export_dlq_messages").count(); + handler.onMessage(new MessageExt()); + verifyNoInteractions(states); + org.junit.jupiter.api.Assertions.assertEquals(before + 1, meters.counter("export_dlq_messages").count()); } } diff --git a/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java b/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java index 19613f251..4fb15cb82 100644 --- a/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java +++ b/file/src/test/java/com/baiyue/file/mq/ExportOutboxPublisherTest.java @@ -2,10 +2,14 @@ package com.baiyue.file.mq; import com.baiyue.file.entity.FileExportOutbox; import com.baiyue.file.repository.ExportOutboxRepository; +import jakarta.annotation.Resource; import org.apache.rocketmq.spring.core.RocketMQTemplate; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.messaging.Message; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; @@ -13,14 +17,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(ExportOutboxPublisher.class) +@TestPropertySource(properties = { + "baiyue.file.export.publisher.enabled=true", + "baiyue.file.export.instance-id=file-1", + "baiyue.file.export.publisher.batch-size=50", + "baiyue.file.export.publisher.lease-seconds=60" +}) class ExportOutboxPublisherTest { + @MockBean private ExportOutboxRepository outbox; + @MockBean private RocketMQTemplate rocket; + @Resource private ExportOutboxPublisher publisher; + @Test void publishesAllDlqLocatorFieldsAsRocketMqUserProperties() { - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); - RocketMQTemplate rocket=mock(RocketMQTemplate.class); FileExportOutbox row=row(); when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row); - new ExportOutboxPublisher(outbox,rocket,"file-1",50,60).publish(); + publisher.publish(); @SuppressWarnings("rawtypes") ArgumentCaptor message=ArgumentCaptor.forClass(Message.class); verify(rocket).syncSend(eq("test-export:EXPORT_TASK"),message.capture(),eq(3000L)); @@ -32,28 +45,22 @@ class ExportOutboxPublisherTest { } @Test void returnsFailedSendsToPendingWithTheFirstBackoffInsteadOfGivingUp() { - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); - RocketMQTemplate rocket=mock(RocketMQTemplate.class); FileExportOutbox row=row();row.setAttemptCount(1); when(outbox.publishableIds(50)).thenReturn(List.of(1L));when(outbox.claim(1L,"file-1",60)).thenReturn(true);when(outbox.find(1L)).thenReturn(row); doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong()); - new ExportOutboxPublisher(outbox,rocket,"file-1",50,60).publish(); + publisher.publish(); verify(outbox).sendFailed(1L,"file-1",5,"broker down"); verify(outbox,never()).sent(anyLong(),anyString()); } @Test void stillRetriesAfterThirtyConsecutiveBrokerFailuresWithoutADeadState() { - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); - RocketMQTemplate rocket=mock(RocketMQTemplate.class); FileExportOutbox row=row();row.setAttemptCount(30); when(outbox.publishableIds(50)).thenReturn(List.of(1L)); when(outbox.claim(1L,"file-1",60)).thenReturn(true); when(outbox.find(1L)).thenReturn(row); doThrow(new RuntimeException("broker down")).when(rocket).syncSend(anyString(),any(Message.class),anyLong()); - ExportOutboxPublisher publisher=new ExportOutboxPublisher(outbox,rocket,"file-1",50,60); - for(int i=0;i<30;i++) publisher.publish(); verify(outbox,times(30)).sendFailed(1L,"file-1",900,"broker down"); diff --git a/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java b/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java index 0f5f4dc19..c316aa08d 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportReconcilerTest.java @@ -5,6 +5,13 @@ import com.baiyue.file.repository.ExportOutboxRepository; import com.baiyue.file.repository.ExportTaskRepository; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.time.Duration; import java.util.List; @@ -12,19 +19,26 @@ import java.util.List; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(classes = {ExportReconciler.class, ExportReconcilerTest.TestConfig.class}) +@TestPropertySource(properties = { + "baiyue.file.export.reconciler.enabled=true", + "baiyue.file.export.reconciler.redispatch-grace-seconds=60", + "baiyue.file.export.reconciler.sent-stale-seconds=600", + "baiyue.file.export.retention-days=7" +}) class ExportReconcilerTest { + @MockBean private ExportTaskRepository tasks; + @MockBean private ExportOutboxRepository outbox; + @MockBean private ExportStateService states; + @MockBean private ExportStorage storage; + @Resource private ExportReconciler reconciler; @Test void performsEveryRecoveryResponsibilityWithoutExecutingBusinessExports() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); - ExportStateService states=mock(ExportStateService.class); - ExportStorage storage=mock(ExportStorage.class); FileExportTask missing=task(1L,"PENDING"), stale=task(2L,"RUNNING"), expired=task(3L,"SUCCEEDED");expired.setObjectKey("exports/3/file.xlsx"); when(outbox.releaseExpiredClaims()).thenReturn(2);when(outbox.redispatchSent(600,60)).thenReturn(3); when(tasks.missingOutbox(60)).thenReturn(List.of(missing));when(states.ensureOutbox(missing)).thenReturn(true); when(tasks.staleRunning()).thenReturn(List.of(stale));when(states.recoverStale(stale)).thenReturn(true); when(tasks.expiredArtifactsDb()).thenReturn(List.of(expired));when(storage.delete(expired.getObjectKey())).thenReturn(true); when(storage.cleanupOrphans(eq(Duration.ofHours(24)),any())).thenReturn(1); - ExportReconciler reconciler=new ExportReconciler(tasks,outbox,states,storage,new SimpleMeterRegistry(),60,600,7); reconciler.reconcile(); @@ -34,11 +48,14 @@ class ExportReconcilerTest { } @Test void deletesOnlyTheConfiguredDailyBatchOfRetainedSentOutboxRows() { - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); - ExportReconciler reconciler=new ExportReconciler(mock(ExportTaskRepository.class),outbox,mock(ExportStateService.class),mock(ExportStorage.class),new SimpleMeterRegistry(),60,600,7); reconciler.cleanupRetainedOutbox(); verify(outbox).deleteRetainedSent(7,5000); } private FileExportTask task(long id,String status){FileExportTask task=new FileExportTask();task.setId(id);task.setStatus(status);return task;} + + @Configuration + static class TestConfig { + @Bean SimpleMeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } + } } diff --git a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java index c1552dbcc..e6dd2aa36 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java @@ -3,23 +3,29 @@ package com.baiyue.file.service; import com.baiyue.file.repository.ExportOutboxRepository; import com.baiyue.file.repository.ExportTaskRepository; import com.baiyue.file.entity.FileExportTask; +import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.mockito.InOrder; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; +@SpringJUnitConfig(ExportStateService.class) class ExportStateServiceTest { + @MockBean private ExportTaskRepository tasks; + @MockBean private ExportOutboxRepository outbox; + @MockBean private ExportCommandFactory commands; + @MockBean private ExportMetrics metrics; + @Resource private ExportStateService service; + @Test void creationLimitsAndDuplicateDetectionRunUnderTheDatabaseGuard() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); - ExportCommandFactory commands=mock(ExportCommandFactory.class); FileExportTask task=task(9L,1,"PENDING"); task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setExportType("CUSTOMER_LIST");task.setParametersJson("{}"); when(commands.create(task,1)).thenReturn(mock(com.baiyue.file.entity.FileExportOutbox.class)); when(outbox.insert(any())).thenReturn(true); - ExportStateService service=new ExportStateService(tasks,outbox,commands,mock(ExportMetrics.class)); ExportStateService.CreationResult result=service.createConstrained(task); @@ -35,12 +41,9 @@ class ExportStateServiceTest { } @Test void duplicateCreationReturnsTheExistingTaskWithoutWritingAnotherOutbox() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); FileExportTask request=task(9L,1,"PENDING"), existing=task(8L,1,"PENDING"); request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}"); when(tasks.recentDuplicate(0L,"PLATFORM",7L,"CUSTOMER_LIST","{}")).thenReturn(existing); - ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class)); ExportStateService.CreationResult result=service.createConstrained(request); @@ -52,12 +55,9 @@ class ExportStateServiceTest { } @Test void activeTaskLimitsAreEnforcedAfterTakingTheCreationGuard() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); FileExportTask request=task(9L,1,"PENDING"); request.setTenantId(0L);request.setAppCode("PLATFORM");request.setRequestedBy(7L);request.setExportType("CUSTOMER_LIST");request.setParametersJson("{}"); when(tasks.activeForUser(0L,"PLATFORM",7L)).thenReturn(3L); - ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class)); assertThrows(com.baiyue.common.core.exception.BizException.class,() -> service.createConstrained(request)); @@ -70,23 +70,17 @@ class ExportStateServiceTest { } @Test void quarantineRequiresOutboxAndTaskToChangeAtomically() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING")); when(outbox.quarantine("event-1",9L,3,"broken")).thenReturn(true); when(tasks.failQuarantined(9L,3,"broken")).thenReturn(false); - ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class)); assertThrows(IllegalStateException.class,() -> service.quarantine("event-1",9L,3,"broken")); verify(tasks).failQuarantined(9L,3,"broken"); } @Test void oldDlqMessageThatMatchesNoOutboxDoesNotTouchTheCurrentTask() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); when(tasks.find(9L)).thenReturn(task(9L,3,"PENDING")); when(outbox.quarantine(anyString(),anyLong(),anyInt(),anyString())).thenReturn(false); - ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class)); assertDoesNotThrow(() -> service.quarantine("old-event",9L,2,"broken")); verify(tasks).find(9L); @@ -94,10 +88,7 @@ class ExportStateServiceTest { } @Test void matchingDlqMessageDoesNotDamageAnAlreadyRunningTask() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportOutboxRepository outbox=mock(ExportOutboxRepository.class); when(tasks.find(9L)).thenReturn(task(9L,3,"RUNNING")); - ExportStateService service=new ExportStateService(tasks,outbox,mock(ExportCommandFactory.class),mock(ExportMetrics.class)); assertDoesNotThrow(() -> service.quarantine("event-1",9L,3,"broken")); verifyNoInteractions(outbox); diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java index 7b7f48511..3fb8ae681 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskExecutorTest.java @@ -4,20 +4,44 @@ import com.baiyue.file.config.ExportDefinitionRegistry; import com.baiyue.file.entity.FileExportTask; import com.baiyue.file.repository.ExportTaskRepository; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; import java.util.concurrent.atomic.AtomicBoolean; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(classes = {ExportTaskExecutor.class, ExportTaskExecutorTest.TestConfig.class}) +@TestPropertySource(properties = { + "baiyue.file.export.task.lease-seconds=300", + "baiyue.file.export.task.heartbeat-seconds=60", + "baiyue.file.export.task.max-duration-minutes=30", + "baiyue.file.export.retention-days=7" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class ExportTaskExecutorTest { + @MockBean private ExportTaskRepository tasks; + @MockBean private ExportDefinitionRegistry definitions; + @MockBean private GenericXlsxExportRunner runner; + @MockBean private ExportStateService states; + @MockBean private ExportStorage storage; + @Resource private ExportTaskExecutor executor; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean MeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } + } + @Test void aCommandThatCannotClaimTheTaskIsAcknowledgedWithoutExecutingOrChangingState() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class); - GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class); - ExportStateService states=mock(ExportStateService.class); when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(false); - ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,mock(ExportStorage.class),new SimpleMeterRegistry(),300,60,30,7); executor.execute(9L,1,"file-1"); @@ -26,10 +50,8 @@ class ExportTaskExecutorTest { } @Test void watchdogRenewsTheLeaseWhileTheRunnerIsStillWriting() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class); - GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class); - ExportStateService states=mock(ExportStateService.class); + ReflectionTestUtils.setField(executor, "leaseSeconds", 5); + ReflectionTestUtils.setField(executor, "heartbeatSeconds", 1); FileExportTask running=task(1,"file-1"); when(tasks.claim(9L,1,"file-1",5,30)).thenReturn(true);when(tasks.find(9L)).thenReturn(running); when(tasks.heartbeat(9L,1,"file-1",5)).thenReturn(true);when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt())).thenReturn(true); @@ -38,7 +60,6 @@ class ExportTaskExecutorTest { Thread.sleep(1200); return new GenericXlsxExportRunner.Result("file.xlsx","exports/9/test.xlsx","application/xlsx",100,1); }); - ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,mock(ExportStorage.class),new SimpleMeterRegistry(),5,1,30,7); executor.execute(9L,1,"file-1"); @@ -47,17 +68,11 @@ class ExportTaskExecutorTest { } @Test void aLateFailureFromAnOldExecutionCannotRetryOrFailANewerVersionOwnedByTheSameInstance() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class); - GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class); - ExportStateService states=mock(ExportStateService.class); - ExportStorage storage=mock(ExportStorage.class); when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true); FileExportTask first=task(1,"file-1"), newer=task(2,"file-1"); when(tasks.find(9L)).thenReturn(first,newer); when(definitions.require("CUSTOMER_LIST")).thenReturn(new ExportDefinitionRegistry.Definition()); when(runner.run(eq(first),any(),any(),any())).thenThrow(new RuntimeException("late failure")); - ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,storage,new SimpleMeterRegistry(),300,60,30,7); executor.execute(9L,1,"file-1"); @@ -66,11 +81,6 @@ class ExportTaskExecutorTest { } @Test void deletesTheGeneratedFileWhenTheFinalDatabaseCommitThrows() { - ExportTaskRepository tasks=mock(ExportTaskRepository.class); - ExportDefinitionRegistry definitions=mock(ExportDefinitionRegistry.class); - GenericXlsxExportRunner runner=mock(GenericXlsxExportRunner.class); - ExportStateService states=mock(ExportStateService.class); - ExportStorage storage=mock(ExportStorage.class); FileExportTask running=task(1,"file-1"); when(tasks.claim(9L,1,"file-1",300,30)).thenReturn(true); when(tasks.find(9L)).thenReturn(running); @@ -80,7 +90,6 @@ class ExportTaskExecutorTest { when(tasks.succeed(anyLong(),anyInt(),anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong(),anyInt())) .thenThrow(new RuntimeException("database unavailable")); when(states.retryAfterFailure(eq(running),eq("file-1"),intThat(value -> value >= 48 && value <= 72),anyString())).thenReturn(true); - ExportTaskExecutor executor=new ExportTaskExecutor(tasks,definitions,runner,states,storage,new SimpleMeterRegistry(),300,60,30,7); executor.execute(9L,1,"file-1"); diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index 5f4a6be31..6eb736d5d 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -8,6 +8,12 @@ import com.baiyue.file.entity.FileExportTask; import com.baiyue.file.repository.ExportTaskRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import com.baiyue.file.config.ExportDefinitionRegistry; import java.util.Map; @@ -18,19 +24,22 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(classes = {ExportTaskService.class, ExportTaskServiceTest.TestConfig.class}) class ExportTaskServiceTest { + @MockBean private ExportTaskRepository repository; + @MockBean private ExportStorage storage; + @MockBean private ExportDefinitionRegistry registry; + @MockBean private ExportStateService states; + @MockBean private ExportMetrics metrics; + @Resource private ExportTaskService service; @Test void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() { - ExportTaskRepository repository = mock(ExportTaskRepository.class); - ExportStateService states=mock(ExportStateService.class); + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation -> new ExportStateService.CreationResult(invocation.getArgument(0), true)); - ExportTaskService service = new ExportTaskService(repository, mock(ExportStorage.class), - new ObjectMapper(), registry(), states, mock(ExportMetrics.class)); var response = service.create(user(1L, Set.of("platform:customer:export")), new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of("status", "ACTIVE"))); @@ -45,9 +54,7 @@ class ExportTaskServiceTest { @Test void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() { - ExportTaskRepository repository = mock(ExportTaskRepository.class); - ExportTaskService service = new ExportTaskService(repository, mock(ExportStorage.class), - new ObjectMapper(), registry(), mock(ExportStateService.class), mock(ExportMetrics.class)); + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); FileExportTask task = new FileExportTask(); task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L); when(repository.find(9L)).thenReturn(task); @@ -59,8 +66,7 @@ class ExportTaskServiceTest { @Test void rejectsParametersDeeperThanTwoLevels() { - ExportTaskService service = new ExportTaskService(mock(ExportTaskRepository.class), mock(ExportStorage.class), - new ObjectMapper(), registry(), mock(ExportStateService.class), mock(ExportMetrics.class)); + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); var request = new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of("level1", Map.of("level2", List.of(Map.of("level3", "value"))))); @@ -71,8 +77,13 @@ class ExportTaskServiceTest { private AuthenticatedUser user(long id, Set permissions) { return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); } - private ExportDefinitionRegistry registry(){ + private ExportDefinitionRegistry.Definition definition(){ var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("iam-service");definition.setProviderPath("/internal/exports/v1/CUSTOMER_LIST/batches");definition.setRequiredPermission("platform:customer:export");definition.setAppScope("PLATFORM");definition.setMaxRows(200000);definition.setMaxAttempts(3);definition.setBatchSize(500); - ExportDefinitionRegistry registry=mock(ExportDefinitionRegistry.class);when(registry.require("CUSTOMER_LIST")).thenReturn(definition);return registry; + return definition; + } + + @Configuration + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } } diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java index 09bf40617..46c32cc53 100644 --- a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -11,6 +11,15 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.nio.file.Files; import java.nio.file.Path; @@ -23,10 +32,21 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(classes = {GenericXlsxExportRunner.class, LocalExportStorage.class, + GenericXlsxExportRunnerTest.TestConfig.class}) class GenericXlsxExportRunnerTest { - @TempDir Path temp; + @TempDir static Path temp; + @MockBean private ExportProviderClient provider; + @SpyBean private LocalExportStorage storage; + @Resource private GenericXlsxExportRunner runner; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("baiyue.file.storage-root", () -> temp.toString()); + registry.add("baiyue.security.internal-service-token", () -> "internal-token"); + registry.add("baiyue.file.export.task.max-file-size-mb", () -> 50); + } @Test void writesRealTypedWorkbookAndCarriesSnapshotAcrossBatches() throws Exception { - ExportProviderClient provider=mock(ExportProviderClient.class); LocalExportStorage storage=new LocalExportStorage(temp.toString());storage.initialize(); List columns=List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18),new ExportColumn("integer","整数",ExportColumnType.INTEGER,12),new ExportColumn("decimal","金额",ExportColumnType.DECIMAL,12),new ExportColumn("flag","布尔",ExportColumnType.BOOLEAN,10),new ExportColumn("date","日期",ExportColumnType.DATE,12),new ExportColumn("time","时间",ExportColumnType.DATETIME,20)); List> firstRows=IntStream.range(0,100).mapToObj(index -> Map.of("text",index==0?"=SUM(1,2)":"row-"+index,"integer",12,"decimal","3.50","flag",true,"date","2026-08-03","time","2026-08-03T21:00:00")).toList(); var first=new ExportBatchResponse(1,"sha256:test","snapshot-9",columns,firstRows,"10",true,101L); @@ -34,7 +54,7 @@ class GenericXlsxExportRunnerTest { when(provider.batch(anyString(),eq("CUSTOMER_LIST"),any(),any())).thenReturn(ApiResponse.success(first),ApiResponse.success(second)); FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}"); var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500); - var result=new GenericXlsxExportRunner(provider,storage,new ObjectMapper(),"internal-token",50).run(task,definition,new AtomicBoolean(),ignored->{ }); + var result=runner.run(task,definition,new AtomicBoolean(),ignored->{ }); assertEquals(101,result.rows()); try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())){ var row=workbook.getSheetAt(0).getRow(1);assertEquals("'=SUM(1,2)",row.getCell(0).getStringCellValue()); @@ -47,8 +67,6 @@ class GenericXlsxExportRunnerTest { } @Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception { - ExportProviderClient provider=mock(ExportProviderClient.class); - LocalExportStorage storage=spy(new LocalExportStorage(temp.toString()));storage.initialize(); var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9", List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)), List.of(Map.of("text","row")),null,false,1L); @@ -56,12 +74,20 @@ class GenericXlsxExportRunnerTest { FileExportTask task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);task.setExportType("CUSTOMER_LIST");task.setTaskName("客户列表");task.setParametersJson("{}"); var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(500); doThrow(new RuntimeException("size unavailable")).when(storage).size(anyString()); + long filesBefore; + try (var paths=Files.walk(temp.resolve("exports"))) { + filesBefore = paths.filter(Files::isRegularFile).count(); + } - assertThrows(ExportFailure.class,() -> new GenericXlsxExportRunner(provider,storage,new ObjectMapper(),"internal-token",50) - .run(task,definition,new AtomicBoolean(),ignored->{ })); + assertThrows(ExportFailure.class,() -> runner.run(task,definition,new AtomicBoolean(),ignored->{ })); try(var paths=Files.walk(temp.resolve("exports"))) { - assertEquals(0,paths.filter(Files::isRegularFile).count()); + assertEquals(filesBefore, paths.filter(Files::isRegularFile).count()); } } + + @Configuration + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } } diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 587de55a6..17b9ae287 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -6,6 +6,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.annotation.Resource; import java.nio.file.Files; import java.nio.file.Path; @@ -16,14 +23,20 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @DisplayName("本地企业资质文件存储功能") +@SpringJUnitConfig(LocalQualificationStorage.class) class LocalQualificationStorageTest { @TempDir - Path storageRoot; + static Path storageRoot; + @Resource private LocalQualificationStorage storage; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("baiyue.file.storage-root", () -> storageRoot.toString()); + } @Test void initializeVerifiesTheActualQualificationMountAndCleansUpItsProbe() throws Exception { - var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); @@ -36,7 +49,6 @@ class LocalQualificationStorageTest { @Test void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception { - var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01}; var upload = new MockMultipartFile("file", "license.png", "image/png", png); @@ -53,7 +65,6 @@ class LocalQualificationStorageTest { @Test void storesPdfQualificationAfterVerifyingItsSignature() throws Exception { - var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes()); @@ -66,7 +77,6 @@ class LocalQualificationStorageTest { @Test void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() { - var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); var upload = new MockMultipartFile("file", "oversized.pdf", "application/pdf", new byte[5 * 1024 * 1024 + 1]); @@ -79,7 +89,6 @@ class LocalQualificationStorageTest { @Test void deletesTheReplacedQualificationByItsValidatedDocumentUrl() { - var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes()); var stored = storage.store(10001L, upload); @@ -91,7 +100,6 @@ class LocalQualificationStorageTest { @Test void doesNotDeleteAQualificationForAnUntrustedDocumentUrl() { - var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes()); var stored = storage.store(10001L, upload); @@ -103,17 +111,17 @@ class LocalQualificationStorageTest { @Test void legacySeedPathCannotBePretendedToBeAStoredFile() { - var storage = new LocalQualificationStorage(storageRoot.toString()); assertThrows(BizException.class, () -> storage.load(10001L, "demo-business-license.jpg")); } @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) void invalidStorageFailsFastDuringStartup() throws Exception { Path invalidRoot = storageRoot.resolve("root-is-file"); Files.writeString(invalidRoot, "not-a-directory"); - var storage = new LocalQualificationStorage(invalidRoot.toString()); + ReflectionTestUtils.setField(storage, "configuredRoot", invalidRoot.toString()); IllegalStateException exception = assertThrows(IllegalStateException.class, storage::initialize); @@ -122,14 +130,15 @@ class LocalQualificationStorageTest { @Test void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception { - var storage = new LocalQualificationStorage(storageRoot.toString()); storage.initialize(); - Files.writeString(storageRoot.resolve("qualifications").resolve("10001"), "阻止创建租户目录"); + long isolatedTenantId = 19999L; + Files.writeString(storageRoot.resolve("qualifications").resolve(String.valueOf(isolatedTenantId)), + "阻止创建租户目录"); byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01}; var upload = new MockMultipartFile("file", "license.png", "image/png", png); BizException exception = assertThrows(BizException.class, - () -> storage.store(10001L, upload)); + () -> storage.store(isolatedTenantId, upload)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); } diff --git a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java index c631d7cf3..fb9960b74 100644 --- a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java +++ b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java @@ -4,8 +4,13 @@ import com.baiyue.file.client.WeightOcrClient; import com.baiyue.file.entity.FileObjectEntity; import com.baiyue.file.mapper.FileObjectMapper; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.nio.file.Path; @@ -13,17 +18,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +@SpringJUnitConfig(MeasurementImageService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MeasurementImageServiceEvidenceTest { + @MockBean private MeasurementImageStorage storage; + @MockBean private FileObjectMapper files; + @MockBean private WeightOcrClient ocr; + @Resource private MeasurementImageService service; @Test - void 尺寸测量证据图片落库但不调用重量Ocr() { - MeasurementImageStorage storage = mock(MeasurementImageStorage.class); - FileObjectMapper files = mock(FileObjectMapper.class); - WeightOcrClient ocr = mock(WeightOcrClient.class); - MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token"); + void shouldHandleCase01() { var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2, 3}); when(storage.store(11L, 21L, image)).thenReturn(new MeasurementImageStorage.Stored( @@ -43,11 +49,7 @@ class MeasurementImageServiceEvidenceTest { } @Test - void 尺寸测量证据图片不能通过重试入口触发重量Ocr() { - MeasurementImageStorage storage = mock(MeasurementImageStorage.class); - FileObjectMapper files = mock(FileObjectMapper.class); - WeightOcrClient ocr = mock(WeightOcrClient.class); - MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token"); + void shouldHandleCase02() { FileObjectEntity object = new FileObjectEntity(); object.setId(41L); object.setTenantId(11L); diff --git a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java index 41ba1d39c..3d8cb46b7 100644 --- a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java @@ -6,8 +6,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.file.client.IamQualificationClient; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.nio.file.Path; @@ -17,11 +21,17 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(QualificationPackageService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class QualificationPackageServiceTest { + @MockBean private LocalQualificationStorage storage; + @MockBean private IamQualificationClient iamClient; + @Resource private QualificationPackageService service; private static final long TENANT_ID = 10001L; private static final String BUSINESS_URL = "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf"; @@ -111,20 +121,15 @@ class QualificationPackageServiceTest { ordered.verify(fixture.storage).deleteByDocumentUrl(TENANT_ID, oldLegalPersonUrl); } - private static Fixture storedFixture() { + private Fixture storedFixture() { var fixture = fixture(); when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenReturn(fixture.legalPersonStored); return fixture; } - private static Fixture fixture() { - var storage = mock(LocalQualificationStorage.class); - var iamClient = mock(IamQualificationClient.class); - var service = new QualificationPackageService(); - ReflectionTestUtils.setField(service, "storage", storage); - ReflectionTestUtils.setField(service, "iamClient", iamClient); - ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + private Fixture fixture() { + reset(storage, iamClient); var businessLicense = new MockMultipartFile( "businessLicense", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); var legalPersonId = new MockMultipartFile( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/TimeConfiguration.java b/firstleg/src/main/java/com/baiyue/firstleg/config/TimeConfiguration.java new file mode 100644 index 000000000..73cecb685 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/TimeConfiguration.java @@ -0,0 +1,15 @@ +package com.baiyue.firstleg.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class TimeConfiguration { + + @Bean + public Clock systemClock() { + return Clock.systemDefaultZone(); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 640bfb417..dd550b6ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -1,5 +1,8 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -23,13 +26,15 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/firstleg/v1/inbounds") public class InternalInboundOrderController { - private final InboundOrderCreationService service; - private final byte[] expectedToken; + @Resource + private InboundOrderCreationService service; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; - public InternalInboundOrderController(InboundOrderCreationService service, - @Value("${baiyue.security.internal-service-token}") String token) { - this.service = service; - this.expectedToken = token.getBytes(StandardCharsets.UTF_8); + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } @PostMapping("/order-approved") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java index 308b766f7..990efd3e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -22,11 +24,8 @@ import org.springframework.http.ResponseEntity; @RestController @RequestMapping("/api/merchant/v1/first-leg/orders") public class MerchantInboundBillingController { - private final MerchantInboundBillingService service; - - public MerchantInboundBillingController(MerchantInboundBillingService service) { - this.service = service; - } + @Resource + private MerchantInboundBillingService service; @GetMapping("/{orderId}/inbound-billing") @PreAuthorize("hasAuthority('merchant:order:view')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java index 51c2b42b1..b8a9d687c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse; import com.baiyue.firstleg.security.MeasurementRequestHeaders; @@ -32,11 +34,8 @@ import org.springframework.web.multipart.MultipartFile; @SecurityRequirement(name = "measurementNonce") @SecurityRequirement(name = "measurementSignature") public class OpenApiInboundMeasurementController { - private final InboundMeasurementOpenApiService service; - - public OpenApiInboundMeasurementController(InboundMeasurementOpenApiService service) { - this.service = service; - } + @Resource + private InboundMeasurementOpenApiService service; @Operation(summary = "提交一条第三方箱规测量结果和图片", description = "multipart 包含 data 原始JSON和 image 图片;签名 body 为 data原文、换行、图片SHA-256,外层仍按 METHOD、PATH、时间戳、Nonce、Idempotency-Key、body SHA-256 六行签名") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java index a5980d05a..362caf316 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; @@ -32,9 +34,8 @@ import java.util.List; @RestController @RequestMapping("/api/platform/v1/fba-pricing") public class PlatformFbaMasterDataController { - private final FbaMasterDataService service; - - public PlatformFbaMasterDataController(FbaMasterDataService service) { this.service = service; } + @Resource + private FbaMasterDataService service; @GetMapping("/customer-rules") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index a4fd76295..12eea8342 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; @@ -28,9 +30,8 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/platform/v1/inbounds/{inboundId}/fees") public class PlatformFbaPricingController { - private final FbaPricingService service; - - public PlatformFbaPricingController(FbaPricingService service) { this.service = service; } + @Resource + private FbaPricingService service; @PostMapping("/calculate") @PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index f92e750d9..1cbe280c0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; @@ -28,14 +30,8 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}") public class PlatformInboundMeasurementController { - private final InboundMeasurementService service; - private final MeasurementSubmissionService submission; - - public PlatformInboundMeasurementController(InboundMeasurementService service, - MeasurementSubmissionService submission) { - this.service = service; - this.submission = submission; - } + @Resource private InboundMeasurementService service; + @Resource private MeasurementSubmissionService submission; @Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。") @GetMapping("/measurement") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index ec4be5c2f..7e258b5c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; @@ -34,14 +36,8 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/platform/v1/inbounds") public class PlatformInboundMeasurementReviewController { - private final MeasurementReviewService service; - private final AutomaticInboundCompletionService automaticCompletion; - - public PlatformInboundMeasurementReviewController(MeasurementReviewService service, - AutomaticInboundCompletionService automaticCompletion) { - this.service = service; - this.automaticCompletion = automaticCompletion; - } + @Resource private MeasurementReviewService service; + @Resource private AutomaticInboundCompletionService automaticCompletion; @BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "测量审核通过") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java index 513503544..3c9f37771 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; @@ -29,11 +31,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/platform/inbounds") public class PlatformInboundOrderController { - private final InboundOrderQueryService service; - - public PlatformInboundOrderController(InboundOrderQueryService service) { - this.service = service; - } + @Resource private InboundOrderQueryService service; @Operation(summary = "分页查询入库单", description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 7b8b604a7..ac373dc86 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.repository; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; @@ -24,27 +26,20 @@ import java.util.List; /** FBA 计费仓储;平台基础资料列表可跨企业读取,其他业务操作强制带租户条件。 */ @Repository public class FbaPricingRepository { - private final CustomerChargeRuleMapper rules; - private final CarrierFbaRateMapper rates; - private final InboundPricingMapper inbounds; - private final InboundFeeMapper fees; - private final InboundFeeItemMapper items; - private final InboundPricingIdempotencyMapper idempotencies; - private final InboundOutboxMapper outbox; - - public FbaPricingRepository(CustomerChargeRuleMapper rules, CarrierFbaRateMapper rates, - InboundPricingMapper inbounds, InboundFeeMapper fees, - InboundFeeItemMapper items, - InboundPricingIdempotencyMapper idempotencies, - InboundOutboxMapper outbox) { - this.rules = rules; - this.rates = rates; - this.inbounds = inbounds; - this.fees = fees; - this.items = items; - this.idempotencies = idempotencies; - this.outbox = outbox; - } + @Resource + private CustomerChargeRuleMapper rules; + @Resource + private CarrierFbaRateMapper rates; + @Resource + private InboundPricingMapper inbounds; + @Resource + private InboundFeeMapper fees; + @Resource + private InboundFeeItemMapper items; + @Resource + private InboundPricingIdempotencyMapper idempotencies; + @Resource + private InboundOutboxMapper outbox; public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } public void insertRate(CarrierFbaRateEntity rate) { rates.insert(rate); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java index 5f343ad61..31074f097 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.repository; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.FulfillmentTaskQuery; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -16,13 +18,8 @@ import org.springframework.util.StringUtils; */ @Repository public class FulfillmentTaskRepository { - private final FulfillmentTaskMapper tasks; - private final AssignmentHistoryMapper histories; - - public FulfillmentTaskRepository(FulfillmentTaskMapper tasks, AssignmentHistoryMapper histories) { - this.tasks = tasks; - this.histories = histories; - } + @Resource private FulfillmentTaskMapper tasks; + @Resource private AssignmentHistoryMapper histories; public FulfillmentTaskEntity findOwned(long tenantId, long taskId) { return tasks.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java index a29ff882a..c01a2766a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementOpenApiRepository.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.repository; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; @@ -12,17 +14,12 @@ import org.springframework.stereotype.Repository; /** 第三方测量开放接口仓储,仅允许读取主单并写测量、明细和幂等记录。 */ @Repository public class InboundMeasurementOpenApiRepository { - private final InboundOrderLookupMapper inboundOrders; - private final InboundMeasurementMapper measurements; - private final InboundMeasurementItemMapper items; - - public InboundMeasurementOpenApiRepository(InboundOrderLookupMapper inboundOrders, - InboundMeasurementMapper measurements, - InboundMeasurementItemMapper items) { - this.inboundOrders = inboundOrders; - this.measurements = measurements; - this.items = items; - } + @Resource + private InboundOrderLookupMapper inboundOrders; + @Resource + private InboundMeasurementMapper measurements; + @Resource + private InboundMeasurementItemMapper items; public InboundOrderLookupEntity findInbound(long tenantId, String inboundCode) { return inboundOrders.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index 6eb622b6b..3f1fa4baa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.repository; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; @@ -20,26 +22,18 @@ import java.util.List; /** 入库测量仓储,所有读取都同时带租户和入库单边界。 */ @Repository public class InboundMeasurementRepository { - private final InboundOrderMapper inboundOrders; - private final InboundMeasurementMapper measurements; - private final InboundMeasurementItemMapper items; - private final InboundWeightResultMapper weights; - private final InboundWeightRevisionMapper revisions; - private final MeasureReviewRecordMapper reviews; - - public InboundMeasurementRepository(InboundOrderMapper inboundOrders, - InboundMeasurementMapper measurements, - InboundMeasurementItemMapper items, - InboundWeightResultMapper weights, - InboundWeightRevisionMapper revisions, - MeasureReviewRecordMapper reviews) { - this.inboundOrders = inboundOrders; - this.measurements = measurements; - this.items = items; - this.weights = weights; - this.revisions = revisions; - this.reviews = reviews; - } + @Resource + private InboundOrderMapper inboundOrders; + @Resource + private InboundMeasurementMapper measurements; + @Resource + private InboundMeasurementItemMapper items; + @Resource + private InboundWeightResultMapper weights; + @Resource + private InboundWeightRevisionMapper revisions; + @Resource + private MeasureReviewRecordMapper reviews; public InboundOrderEntity findInbound(long tenantId, long inboundId) { return inboundOrders.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 11018db93..f0aba181c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.repository; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; @@ -29,27 +31,18 @@ import java.util.stream.Collectors; /** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */ @Repository public class InboundOrderRepository { - private final InboundOrderMapper orders; - private final InboundTransportSnapshotMapper transports; - private final InboundPalletAllocationMapper pallets; - private final InboundFileMapper files; - private final InboundInternalNoteMapper notes; - private final InboundStatusHistoryMapper histories; - - public InboundOrderRepository( - InboundOrderMapper orders, - InboundTransportSnapshotMapper transports, - InboundPalletAllocationMapper pallets, - InboundFileMapper files, - InboundInternalNoteMapper notes, - InboundStatusHistoryMapper histories) { - this.orders = orders; - this.transports = transports; - this.pallets = pallets; - this.files = files; - this.notes = notes; - this.histories = histories; - } + @Resource + private InboundOrderMapper orders; + @Resource + private InboundTransportSnapshotMapper transports; + @Resource + private InboundPalletAllocationMapper pallets; + @Resource + private InboundFileMapper files; + @Resource + private InboundInternalNoteMapper notes; + @Resource + private InboundStatusHistoryMapper histories; public PageResult page(Long tenantId, InboundOrderQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java index c8eb71967..9c5f5f5ed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundPalletAllocationRepository.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.repository; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.firstleg.client.WarehousePalletLocationClient.BatchResult; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; @@ -15,11 +17,7 @@ import java.util.stream.Collectors; @Repository public class InboundPalletAllocationRepository { - private final InboundPalletAllocationMapper mapper; - - public InboundPalletAllocationRepository(InboundPalletAllocationMapper mapper) { - this.mapper = mapper; - } + @Resource private InboundPalletAllocationMapper mapper; @Transactional public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java index 20685a3b8..4e2fb4061 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundWorkflowRepository.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.repository; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; @@ -23,32 +25,22 @@ import org.springframework.stereotype.Repository; /** 入库审核和完成编排仓储;所有业务查询强制携带租户。 */ @Repository public class InboundWorkflowRepository { - private final InboundOrderMapper orders; - private final InboundMeasurementMapper measurements; - private final MeasureReviewRecordMapper reviews; - private final InboundStatusHistoryMapper histories; - private final InboundIdempotencyMapper idempotencies; - private final InboundFeeMapper fees; - private final InboundOutboxMapper outboxes; - private final InboundAllocationQueryMapper allocations; - - public InboundWorkflowRepository(InboundOrderMapper orders, - InboundMeasurementMapper measurements, - MeasureReviewRecordMapper reviews, - InboundStatusHistoryMapper histories, - InboundIdempotencyMapper idempotencies, - InboundFeeMapper fees, - InboundOutboxMapper outboxes, - InboundAllocationQueryMapper allocations) { - this.orders = orders; - this.measurements = measurements; - this.reviews = reviews; - this.histories = histories; - this.idempotencies = idempotencies; - this.fees = fees; - this.outboxes = outboxes; - this.allocations = allocations; - } + @Resource + private InboundOrderMapper orders; + @Resource + private InboundMeasurementMapper measurements; + @Resource + private MeasureReviewRecordMapper reviews; + @Resource + private InboundStatusHistoryMapper histories; + @Resource + private InboundIdempotencyMapper idempotencies; + @Resource + private InboundFeeMapper fees; + @Resource + private InboundOutboxMapper outboxes; + @Resource + private InboundAllocationQueryMapper allocations; public InboundOrderEntity findOwned(long tenantId, long inboundId) { return orders.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticator.java b/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticator.java index b8b39acd8..088810acd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticator.java @@ -1,9 +1,10 @@ package com.baiyue.firstleg.security; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.config.MeasurementOpenApiProperties; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -21,23 +22,12 @@ import java.util.Map; @Component public class MeasurementRequestAuthenticator { private static final String HMAC_ALGORITHM = "HmacSHA256"; - private final MeasurementOpenApiProperties properties; - private final NonceReplayGuard nonceReplayGuard; - private final Clock clock; - - @Autowired - public MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties, - NonceReplayGuard nonceReplayGuard) { - this(properties, nonceReplayGuard, Clock.systemUTC()); - } - - MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties, - NonceReplayGuard nonceReplayGuard, - Clock clock) { - this.properties = properties; - this.nonceReplayGuard = nonceReplayGuard; - this.clock = clock; - } + @Resource + private MeasurementOpenApiProperties properties; + @Resource + private NonceReplayGuard nonceReplayGuard; + @Resource + private Clock clock; public AuthenticatedMeasurementApp authenticate(String method, String path, MeasurementRequestHeaders headers, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java index f07f53dd4..2cfd4c1e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/security/RedisNonceReplayGuard.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.security; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; @@ -9,11 +10,8 @@ import java.time.Duration; @Component public class RedisNonceReplayGuard implements NonceReplayGuard { private static final String KEY_PREFIX = "baiyue:firstleg:measurement-openapi:nonce:"; - private final StringRedisTemplate redisTemplate; - - public RedisNonceReplayGuard(StringRedisTemplate redisTemplate) { - this.redisTemplate = redisTemplate; - } + @Autowired + private StringRedisTemplate redisTemplate; @Override public boolean claim(String appKey, String nonce, Duration ttl) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java index 7b35b6778..1cc7de101 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; @@ -9,17 +11,12 @@ import org.springframework.stereotype.Service; @Service public class AutomaticInboundCompletionService { - private final FbaPricingService pricing; - private final InboundWorkflowRepository workflow; - private final InboundCompletionOrchestrator completion; - - public AutomaticInboundCompletionService(FbaPricingService pricing, - InboundWorkflowRepository workflow, - InboundCompletionOrchestrator completion) { - this.pricing = pricing; - this.workflow = workflow; - this.completion = completion; - } + @Resource + private FbaPricingService pricing; + @Resource + private InboundWorkflowRepository workflow; + @Resource + private InboundCompletionOrchestrator completion; public FeeSnapshotResponse completeApproved(long tenantId, long inboundId, long operatorId, String operatorName, long measurementId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java index 2924f4e6c..59b60be6a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; @@ -17,9 +19,8 @@ import java.util.Objects; /** 客户规则和船司价格版本管理。版本内容创建后不可原地修改。 */ @Service public class FbaMasterDataService { - private final FbaPricingRepository repository; - - public FbaMasterDataService(FbaPricingRepository repository) { this.repository = repository; } + @Resource + private FbaPricingRepository repository; @Transactional public CustomerChargeRuleEntity createRuleVersion(long tenantId, long operatorId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 6472f197c..972a8b289 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; @@ -12,7 +14,6 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.repository.FbaPricingRepository; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -32,18 +33,10 @@ import java.util.List; @Service public class FbaPricingService { private static final int MONEY_SCALE = 2; - private final FbaPricingRepository repository; - private final Clock clock; - - @Autowired - public FbaPricingService(FbaPricingRepository repository) { - this(repository, Clock.systemDefaultZone()); - } - - FbaPricingService(FbaPricingRepository repository, Clock clock) { - this.repository = repository; - this.clock = clock; - } + @Resource + private FbaPricingRepository repository; + @Resource + private Clock clock; @Transactional public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java index 560ca24cb..d27e85257 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundFeeEntity; @@ -21,14 +23,10 @@ import java.time.LocalDateTime; */ @Service public class InboundCompletionOrchestrator { - private final InboundWorkflowRepository repository; - private final InboundIdempotencyService idempotency; - - public InboundCompletionOrchestrator(InboundWorkflowRepository repository, - InboundIdempotencyService idempotency) { - this.repository = repository; - this.idempotency = idempotency; - } + @Resource + private InboundWorkflowRepository repository; + @Resource + private InboundIdempotencyService idempotency; /** * 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java index 29b7a6488..78b09c04f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundIdempotencyService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundIdempotencyEntity; @@ -17,11 +19,8 @@ import java.util.HexFormat; /** 入库写操作的统一持久化幂等边界。 */ @Service public class InboundIdempotencyService { - private final InboundWorkflowRepository repository; - - public InboundIdempotencyService(InboundWorkflowRepository repository) { - this.repository = repository; - } + @Resource + private InboundWorkflowRepository repository; public Context begin(long tenantId, long operatorId, long inboundId, String operation, String idempotencyKey, String canonicalRequest) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java index 5a0b8a7cf..7b1e7e8c3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; @@ -50,36 +52,24 @@ public class InboundMeasurementOpenApiService { private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; - private final InboundMeasurementOpenApiRepository repository; - private final MeasurementRequestAuthenticator authenticator; - private final ThirdPartyCallLogService callLogService; - private final OrderCargoClient orderCargoClient; - private final MeasurementImageClient imageClient; - private final String internalToken; - private final ObjectMapper objectMapper; - private final Validator validator; - private final TransactionTemplate transactionTemplate; - - public InboundMeasurementOpenApiService( - InboundMeasurementOpenApiRepository repository, - MeasurementRequestAuthenticator authenticator, - ThirdPartyCallLogService callLogService, - OrderCargoClient orderCargoClient, - MeasurementImageClient imageClient, - @Value("${baiyue.security.internal-service-token}") String internalToken, - ObjectMapper objectMapper, - Validator validator, - TransactionTemplate transactionTemplate) { - this.repository = repository; - this.authenticator = authenticator; - this.callLogService = callLogService; - this.orderCargoClient = orderCargoClient; - this.imageClient = imageClient; - this.internalToken = internalToken; - this.objectMapper = objectMapper; - this.validator = validator; - this.transactionTemplate = transactionTemplate; - } + @Resource + private InboundMeasurementOpenApiRepository repository; + @Resource + private MeasurementRequestAuthenticator authenticator; + @Resource + private ThirdPartyCallLogService callLogService; + @Resource + private OrderCargoClient orderCargoClient; + @Resource + private MeasurementImageClient imageClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + @Resource + private ObjectMapper objectMapper; + @Resource + private Validator validator; + @Resource + private TransactionTemplate transactionTemplate; public EventResponse receive(MeasurementRequestHeaders headers, String rawData, MultipartFile image) { requireImage(image); 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 e39f6662e..a7251ef8b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.InboundMeasurementRequests; @@ -36,14 +38,10 @@ public class InboundMeasurementService { Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237"); - private final InboundMeasurementRepository repository; - private final MeasurementBatchService batchService; - - public InboundMeasurementService(InboundMeasurementRepository repository, - MeasurementBatchService batchService) { - this.repository = repository; - this.batchService = batchService; - } + @Resource + private InboundMeasurementRepository repository; + @Resource + private MeasurementBatchService batchService; public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 88f6367db..5f51a2aab 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.dto.InboundOrderReference; @@ -16,13 +18,10 @@ import java.util.List; /** 将已审核的订单快照幂等转换为待测量入库单。 */ @Service public class InboundOrderCreationService { - private final InboundOrderMapper orders; - private final InboundStatusHistoryMapper histories; - - public InboundOrderCreationService(InboundOrderMapper orders, InboundStatusHistoryMapper histories) { - this.orders = orders; - this.histories = histories; - } + @Resource + private InboundOrderMapper orders; + @Resource + private InboundStatusHistoryMapper histories; @Transactional public long createFromApprovedOrder(OrderApprovedInboundCommand command) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java index d8a20fe84..5ed5b2e27 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -15,11 +17,8 @@ import org.springframework.util.StringUtils; /** 入库单列表和详情只读应用服务,不执行任何状态推进。 */ @Service public class InboundOrderQueryService { - private final InboundOrderRepository repository; - - public InboundOrderQueryService(InboundOrderRepository repository) { - this.repository = repository; - } + @Resource + private InboundOrderRepository repository; public PageResult page(Long tenantId, InboundOrderQuery query) { validateQuery(query); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 397fbe730..81356891e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.MeasurementBatchCommand; @@ -31,11 +33,8 @@ public class MeasurementBatchService { static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH"; - private final InboundMeasurementRepository repository; - - public MeasurementBatchService(InboundMeasurementRepository repository) { - this.repository = repository; - } + @Resource + private InboundMeasurementRepository repository; @Transactional public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index 7e6c6232f..6d41023ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -18,22 +20,16 @@ import java.time.LocalDateTime; @Service public class MeasurementLocationOccupationService { private static final String SOURCE_SERVICE = "firstleg-service"; - private final WarehousePalletLocationClient warehouse; - private final InboundPalletAllocationRepository allocations; - private final InboundWorkflowRepository workflow; - private final InboundProperties properties; - private final String internalToken; - - public MeasurementLocationOccupationService(WarehousePalletLocationClient warehouse, - InboundPalletAllocationRepository allocations, InboundWorkflowRepository workflow, - InboundProperties properties, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.warehouse = warehouse; - this.allocations = allocations; - this.workflow = workflow; - this.properties = properties; - this.internalToken = internalToken; - } + @Resource + private WarehousePalletLocationClient warehouse; + @Resource + private InboundPalletAllocationRepository allocations; + @Resource + private InboundWorkflowRepository workflow; + @Resource + private InboundProperties properties; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; public void occupy(long tenantId, long inboundId, long operatorId, long measurementId, int measurementVersion) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index 488b8b117..bb9ffeeb0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -22,17 +24,12 @@ import java.time.LocalDateTime; */ @Service public class MeasurementReviewService { - private final InboundWorkflowRepository repository; - private final InboundIdempotencyService idempotency; - private final InboundProperties properties; - - public MeasurementReviewService(InboundWorkflowRepository repository, - InboundIdempotencyService idempotency, - InboundProperties properties) { - this.repository = repository; - this.idempotency = idempotency; - this.properties = properties; - } + @Resource + private InboundWorkflowRepository repository; + @Resource + private InboundIdempotencyService idempotency; + @Resource + private InboundProperties properties; @Transactional public void approve(long tenantId, long operatorId, String operatorName, long inboundId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java index 6e14b3230..6a2730250 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import org.springframework.stereotype.Service; @@ -7,14 +9,10 @@ import org.springframework.transaction.annotation.Transactional; @Service public class MeasurementSubmissionService { - private final InboundMeasurementService measurements; - private final MeasurementLocationOccupationService locations; - - public MeasurementSubmissionService(InboundMeasurementService measurements, - MeasurementLocationOccupationService locations) { - this.measurements = measurements; - this.locations = locations; - } + @Resource + private InboundMeasurementService measurements; + @Resource + private MeasurementLocationOccupationService locations; @Transactional public InboundMeasurementResponses.MeasurementSubmitted submit( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 8ae6d844a..2486e053b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -1,5 +1,8 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.MerchantInboundBillingResponse; @@ -15,23 +18,16 @@ import java.util.List; @Service public class MerchantInboundBillingService { - private final InboundOrderRepository orders; - private final FbaPricingService pricing; - private final InboundMeasurementService measurements; - private final MeasurementImageClient imageClient; - private final String internalToken; - - public MerchantInboundBillingService(InboundOrderRepository orders, FbaPricingService pricing, - InboundMeasurementService measurements, - MeasurementImageClient imageClient, - @org.springframework.beans.factory.annotation.Value( - "${baiyue.security.internal-service-token}") String internalToken) { - this.orders = orders; - this.pricing = pricing; - this.measurements = measurements; - this.imageClient = imageClient; - this.internalToken = internalToken; - } + @Resource + private InboundOrderRepository orders; + @Resource + private FbaPricingService pricing; + @Resource + private InboundMeasurementService measurements; + @Resource + private MeasurementImageClient imageClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; public MerchantInboundBillingResponse find(long tenantId, long orderId) { InboundOrderEntity inbound = orders.findByOrderId(tenantId, orderId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java index dbf12aee9..aa4511fbd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ThirdPartyCallLogService.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.service; +import jakarta.annotation.Resource; + import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.firstleg.config.MeasurementOpenApiProperties; @@ -17,14 +19,10 @@ import java.time.LocalDateTime; /** 将每次第三方 API 调用独立留档,业务事务回滚也不会丢失失败记录。 */ @Service public class ThirdPartyCallLogService { - private final ThirdPartyCallLogMapper mapper; - private final MeasurementOpenApiProperties properties; - - public ThirdPartyCallLogService(ThirdPartyCallLogMapper mapper, - MeasurementOpenApiProperties properties) { - this.mapper = mapper; - this.properties = properties; - } + @Resource + private ThirdPartyCallLogMapper mapper; + @Resource + private MeasurementOpenApiProperties properties; @Transactional(propagation = Propagation.REQUIRES_NEW) public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody) { diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 64d309867..d43f329e2 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -48,6 +48,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 37f5ad83e..6413d93f1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -3,7 +3,11 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.service.InboundOrderCreationService; +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.util.List; @@ -13,13 +17,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(InternalInboundOrderController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalInboundOrderReferenceControllerTest { - private final InboundOrderCreationService service = mock(InboundOrderCreationService.class); - private final InternalInboundOrderController controller = - new InternalInboundOrderController(service, "internal-token"); + @MockBean + private InboundOrderCreationService service; + @Resource + private InternalInboundOrderController controller; @Test - void 使用内部令牌按租户批量查询订单入仓编码() { + void shouldHandleCase01() { var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L, 77L, 78L)); when(service.findReferences(10001L, List.of(77L, 78L))).thenReturn(List.of( new InboundOrderReference.Item(77L, 88L, "RK-77", "WAIT_MEASURE"))); @@ -32,7 +39,7 @@ class InternalInboundOrderReferenceControllerTest { } @Test - void 错误内部令牌不能查询任意租户的入仓编码() { + void shouldHandleCase02() { var request = new InboundOrderReference.BatchRequest(10001L, List.of(77L)); assertThatThrownBy(() -> controller.references("bad-token", request)) .isInstanceOf(BizException.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java index 7db539597..afa4b83e1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java @@ -3,8 +3,11 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.service.MerchantInboundBillingService; +import jakarta.annotation.Resource; 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 java.util.Set; @@ -12,12 +15,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +@SpringJUnitConfig(MerchantInboundBillingController.class) class MerchantInboundBillingControllerContractTest { - private final MerchantInboundBillingController controller = - new MerchantInboundBillingController(mock(MerchantInboundBillingService.class)); + @MockBean + private MerchantInboundBillingService service; + @Resource + private MerchantInboundBillingController controller; @Test - void 客户费用查询复用订单查看权限且不提供确认接口() throws Exception { + void shouldHandleCase01() throws Exception { var method = MerchantInboundBillingController.class.getDeclaredMethod( "find", AuthenticatedUser.class, long.class); assertThat(method.getAnnotation(PreAuthorize.class).value()) @@ -27,13 +33,13 @@ class MerchantInboundBillingControllerContractTest { } @Test - void 平台身份不能越权调用客户费用接口() { + void shouldHandleCase02() { var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of()); assertThatThrownBy(() -> controller.find(platform, 77L)).isInstanceOf(BizException.class); } @Test - void 商户测量图片复用订单查看权限() throws Exception { + void shouldHandleCase03() throws Exception { var method = MerchantInboundBillingController.class.getDeclaredMethod( "image", AuthenticatedUser.class, long.class, long.class); assertThat(method.getAnnotation(PreAuthorize.class).value()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 5766d4fac..12c9341ea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -16,6 +16,9 @@ import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.Collection; import java.util.List; @@ -25,7 +28,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(FbaPricingRepository.class) class FbaPricingRepositoryRuleListTest { + @MockBean private CustomerChargeRuleMapper rules; + @MockBean private CarrierFbaRateMapper rates; + @MockBean private InboundPricingMapper pricing; + @MockBean private InboundFeeMapper fees; + @MockBean private InboundFeeItemMapper feeItems; + @MockBean private InboundPricingIdempotencyMapper idempotency; + @MockBean private InboundOutboxMapper outbox; + @Resource private FbaPricingRepository repository; @BeforeAll static void initializeEntityMetadata() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), @@ -57,11 +69,9 @@ class FbaPricingRepositoryRuleListTest { @Test void internalVersionCalculationKeepsTenantAndRuleCodeFilters() { - CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class); CustomerChargeRuleEntity current = new CustomerChargeRuleEntity(); current.setRuleVersion(3); when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(current)); - FbaPricingRepository repository = repository(rules); int result = repository.nextRuleVersion(10001L, "CR-1"); @@ -75,10 +85,8 @@ class FbaPricingRepositoryRuleListTest { assertThat(result).isEqualTo(4); } - private static Collection queryValues(Long tenantId, String ruleCode) { - CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class); + private Collection queryValues(Long tenantId, String ruleCode) { when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); - FbaPricingRepository repository = repository(rules); repository.listRules(tenantId, ruleCode); @@ -90,10 +98,8 @@ class FbaPricingRepositoryRuleListTest { return wrapper.getValue().getParamNameValuePairs().values(); } - private static Collection rateQueryValues(Long tenantId, String rateCode) { - CarrierFbaRateMapper rates = mock(CarrierFbaRateMapper.class); + private Collection rateQueryValues(Long tenantId, String rateCode) { when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); - FbaPricingRepository repository = repository(mock(CustomerChargeRuleMapper.class), rates); repository.listRates(tenantId, rateCode); @@ -105,14 +111,4 @@ class FbaPricingRepositoryRuleListTest { return wrapper.getValue().getParamNameValuePairs().values(); } - private static FbaPricingRepository repository(CustomerChargeRuleMapper rules) { - return repository(rules, mock(CarrierFbaRateMapper.class)); - } - - private static FbaPricingRepository repository( - CustomerChargeRuleMapper rules, CarrierFbaRateMapper rates) { - return new FbaPricingRepository(rules, rates, - mock(InboundPricingMapper.class), mock(InboundFeeMapper.class), mock(InboundFeeItemMapper.class), - mock(InboundPricingIdempotencyMapper.class), mock(InboundOutboxMapper.class)); - } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index b1f203a72..9d8eb8804 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -18,6 +18,9 @@ import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; import org.junit.jupiter.api.Test; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -26,22 +29,22 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; +@SpringJUnitConfig(InboundOrderRepository.class) class InboundOrderRepositoryTest { + @MockBean private InboundOrderMapper orders; + @MockBean private InboundTransportSnapshotMapper transports; + @MockBean private InboundPalletAllocationMapper pallets; + @MockBean private InboundFileMapper files; + @MockBean private InboundInternalNoteMapper notes; + @MockBean private InboundStatusHistoryMapper histories; + @Resource private InboundOrderRepository repository; @Test @SuppressWarnings({"rawtypes", "unchecked"}) void shouldApplyTenantCompanyAndWarehouseFilters() { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); - InboundOrderMapper orders = mock(InboundOrderMapper.class); when(orders.selectPage(any(Page.class), any(Wrapper.class))) .thenReturn(new Page(1, 20)); - InboundOrderRepository repository = new InboundOrderRepository( - orders, - mock(InboundTransportSnapshotMapper.class), - mock(InboundPalletAllocationMapper.class), - mock(InboundFileMapper.class), - mock(InboundInternalNoteMapper.class), - mock(InboundStatusHistoryMapper.class)); InboundOrderQuery query = new InboundOrderQuery( null, null, null, null, null, null, null, null, null, null, null, null, 20001L, 30001L, null, null, null, 1, 20); @@ -69,8 +72,6 @@ class InboundOrderRepositoryTest { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundPalletAllocationEntity.class); - InboundOrderMapper orders = mock(InboundOrderMapper.class); - InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class); Page page = new Page<>(1, 20); InboundOrderEntity first = new InboundOrderEntity(); first.setId(1L); @@ -84,13 +85,6 @@ class InboundOrderRepositoryTest { allocation(1L, 1, "A0"), allocation(1L, 2, "A2"), allocation(2L, 1, "B1"))); - InboundOrderRepository repository = new InboundOrderRepository( - orders, - mock(InboundTransportSnapshotMapper.class), - pallets, - mock(InboundFileMapper.class), - mock(InboundInternalNoteMapper.class), - mock(InboundStatusHistoryMapper.class)); var result = repository.page(10001L, new InboundOrderQuery( null, null, null, null, null, null, null, null, @@ -118,9 +112,6 @@ class InboundOrderRepositoryTest { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundPalletAllocationEntity.class); - InboundOrderMapper orders = mock(InboundOrderMapper.class); - InboundTransportSnapshotMapper transports = mock(InboundTransportSnapshotMapper.class); - InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class); Page page = new Page<>(1, 20); page.setRecords(java.util.List.of(order(1L, 10001L), order(2L, 10002L))); when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); @@ -132,9 +123,6 @@ class InboundOrderRepositoryTest { allocation(1L, 10001L, 1, "A1"), allocation(2L, 10002L, 1, "B1"), allocation(1L, 10002L, 2, "WRONG"))); - InboundOrderRepository repository = new InboundOrderRepository( - orders, transports, pallets, mock(InboundFileMapper.class), - mock(InboundInternalNoteMapper.class), mock(InboundStatusHistoryMapper.class)); var result = repository.page(null, new InboundOrderQuery( null, null, null, null, null, null, null, null, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticatorTest.java index a25ec6c0c..dcd8fd0db 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/security/MeasurementRequestAuthenticatorTest.java @@ -3,8 +3,14 @@ package com.baiyue.firstleg.security; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.config.MeasurementOpenApiProperties; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.Clock; import java.time.Instant; @@ -13,19 +19,34 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; @DisplayName("第三方测量请求签名校验") +@SpringJUnitConfig(classes = {MeasurementRequestAuthenticator.class, + MeasurementRequestAuthenticatorTest.TestConfig.class}) class MeasurementRequestAuthenticatorTest { private static final Instant NOW = Instant.parse("2026-08-01T02:30:00Z"); private static final String SECRET = "test-secret-at-least-32-bytes-long"; + @MockBean private NonceReplayGuard guard; + @Resource private MeasurementOpenApiProperties properties; + @Resource private MeasurementRequestAuthenticator authenticator; + + @BeforeEach + void setUp() { + properties.setEnabled(true); + MeasurementOpenApiProperties.AppCredential credential = new MeasurementOpenApiProperties.AppCredential(); + credential.setSecret(SECRET); + credential.setTenantId(10001L); + credential.setSubjectId(90001L); + credential.setWarehouseBindings(Map.of("CN-YW-01", 30001L)); + properties.setApps(Map.of("device-provider", credential)); + } @Test @DisplayName("合法签名按六行规范串校验并占用Nonce") void shouldAcceptValidSignatureAndClaimNonce() { - MeasurementOpenApiProperties properties = properties(); - NonceReplayGuard guard = (appKey, nonce, ttl) -> true; - MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator( - properties, guard, Clock.fixed(NOW, ZoneOffset.UTC)); + when(guard.claim(any(), any(), any())).thenReturn(true); String body = "{\"eventNo\":\"M1\"}"; String timestamp = String.valueOf(NOW.toEpochMilli()); String canonical = MeasurementRequestAuthenticator.canonicalString("POST", @@ -46,12 +67,10 @@ class MeasurementRequestAuthenticatorTest { @DisplayName("非法签名返回统一未认证业务码且不占用Nonce") void shouldRejectInvalidSignatureBeforeClaimingNonce() { int[] claims = {0}; - NonceReplayGuard guard = (appKey, nonce, ttl) -> { + when(guard.claim(any(), any(), any())).thenAnswer(invocation -> { claims[0]++; return true; - }; - MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator( - properties(), guard, Clock.fixed(NOW, ZoneOffset.UTC)); + }); assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements", new MeasurementRequestHeaders("device-provider", String.valueOf(NOW.toEpochMilli()), @@ -65,8 +84,7 @@ class MeasurementRequestAuthenticatorTest { @Test @DisplayName("相同应用重复Nonce被拒绝") void shouldRejectReplayedNonce() { - MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator( - properties(), (appKey, nonce, ttl) -> false, Clock.fixed(NOW, ZoneOffset.UTC)); + when(guard.claim(any(), any(), any())).thenReturn(false); String timestamp = String.valueOf(NOW.toEpochMilli()); String body = "{}"; String canonical = MeasurementRequestAuthenticator.canonicalString("POST", @@ -81,15 +99,16 @@ class MeasurementRequestAuthenticatorTest { .isEqualTo(ResultCode.CONFLICT)); } - private MeasurementOpenApiProperties properties() { - MeasurementOpenApiProperties properties = new MeasurementOpenApiProperties(); - properties.setEnabled(true); - MeasurementOpenApiProperties.AppCredential credential = new MeasurementOpenApiProperties.AppCredential(); - credential.setSecret(SECRET); - credential.setTenantId(10001L); - credential.setSubjectId(90001L); - credential.setWarehouseBindings(Map.of("CN-YW-01", 30001L)); - properties.setApps(Map.of("device-provider", credential)); - return properties; + @Configuration + static class TestConfig { + @Bean + MeasurementOpenApiProperties properties() { + return new MeasurementOpenApiProperties(); + } + + @Bean + Clock clock() { + return Clock.fixed(NOW, ZoneOffset.UTC); + } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java index d5757d5c3..bde1947ae 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java @@ -3,7 +3,10 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundWorkflowRepository; +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 static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyLong; @@ -13,15 +16,15 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(AutomaticInboundCompletionService.class) class AutomaticInboundCompletionServiceTest { - private final FbaPricingService pricing = mock(FbaPricingService.class); - private final InboundWorkflowRepository workflow = mock(InboundWorkflowRepository.class); - private final InboundCompletionOrchestrator completion = mock(InboundCompletionOrchestrator.class); - private final AutomaticInboundCompletionService service = - new AutomaticInboundCompletionService(pricing, workflow, completion); + @MockBean private FbaPricingService pricing; + @MockBean private InboundWorkflowRepository workflow; + @MockBean private InboundCompletionOrchestrator completion; + @Resource private AutomaticInboundCompletionService service; @Test - void 客服审核通过后自动计费发布并完成入库() { + void shouldHandleCase01() { FeeSnapshotResponse fee = fee(501L); when(pricing.latest(10001L, 88L)).thenReturn(fee); when(workflow.findOwned(10001L, 88L)).thenReturn( @@ -43,7 +46,7 @@ class AutomaticInboundCompletionServiceTest { } @Test - void 已完成入库的审核重放只返回最新费用() { + void shouldHandleCase02() { FeeSnapshotResponse fee = fee(501L); when(workflow.findOwned(10001L, 88L)).thenReturn(order("INBOUNDED", "SENT", 8)); when(pricing.latest(10001L, 88L)).thenReturn(fee); @@ -58,7 +61,7 @@ class AutomaticInboundCompletionServiceTest { } @Test - void 中断在入库处理中时续跑完成而不重复计费() { + void shouldHandleCase03() { FeeSnapshotResponse fee = fee(501L); when(pricing.latest(10001L, 88L)).thenReturn(fee); when(workflow.findOwned(10001L, 88L)).thenReturn( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java index 9d60dadc3..b0e85db55 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java @@ -4,8 +4,11 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.repository.FbaPricingRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; 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 java.time.LocalDateTime; @@ -20,18 +23,22 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("FBA计费基础资料版本") +@SpringJUnitConfig(FbaMasterDataService.class) class FbaMasterDataServiceTest { private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0); + @MockBean + private FbaPricingRepository repository; + @Resource + private FbaMasterDataService service; + @Test @DisplayName("平台列表不传企业时委托仓储查询全部企业规则") void listsRulesAcrossTenantsWithoutTenantFilter() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); List expected = List.of(activeRule(AUGUST, null)); when(repository.listRules(null, "CR-1")).thenReturn(expected); - List result = - new FbaMasterDataService(repository).listRules(null, "CR-1"); + List result = service.listRules(null, "CR-1"); assertThat(result).isSameAs(expected); verify(repository).listRules(null, "CR-1"); @@ -40,7 +47,6 @@ class FbaMasterDataServiceTest { @Test @DisplayName("半开有效期首尾相接不视为重叠") void acceptsAdjacentHalfOpenPeriods() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); CustomerChargeRuleEntity old = activeRule(AUGUST.minusMonths(1), AUGUST); when(repository.listRules(1L, null)).thenReturn(List.of(old)); when(repository.listRules(1L, "CR-1")).thenReturn(List.of(old)); @@ -48,8 +54,8 @@ class FbaMasterDataServiceTest { doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(10L); return null; }) .when(repository).insertRule(any()); - CustomerChargeRuleEntity created = new FbaMasterDataService(repository) - .createRuleVersion(1L, 2L, request(AUGUST, AUGUST.plusMonths(1))); + CustomerChargeRuleEntity created = service.createRuleVersion( + 1L, 2L, request(AUGUST, AUGUST.plusMonths(1))); assertThat(created.getId()).isEqualTo(10L); assertThat(created.getRuleVersion()).isEqualTo(2); @@ -58,12 +64,11 @@ class FbaMasterDataServiceTest { @Test @DisplayName("任意重叠有效期在保存阶段拒绝避免多规则匹配") void rejectsOverlappingPeriods() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); when(repository.listRules(1L, null)).thenReturn(List.of( activeRule(AUGUST, AUGUST.plusMonths(1)))); - assertThatThrownBy(() -> new FbaMasterDataService(repository) - .createRuleVersion(1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2)))) + assertThatThrownBy(() -> service.createRuleVersion( + 1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2)))) .isInstanceOf(BizException.class) .hasMessageContaining("有效期重叠"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 8f4f253ab..f77dbbd5b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -10,9 +10,14 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.repository.FbaPricingRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.time.Clock; @@ -35,16 +40,19 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("FBA费用状态机") +@SpringJUnitConfig(classes = {FbaPricingService.class, FbaPricingServiceTest.TestConfig.class}) class FbaPricingServiceTest { private static final long TENANT = 10001L; private static final long OPERATOR = 20001L; private static final long INBOUND = 30001L; private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0); + @MockBean private FbaPricingRepository repository; + @Resource private FbaPricingService service; @Test @DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入") void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() { - FbaPricingRepository repository = readyRepository(); + readyRepository(); InboundPricingEntity inbound = inbound("100.000", "1.0001"); CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING"); CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00"); @@ -52,7 +60,7 @@ class FbaPricingServiceTest { when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate)); - FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); assertThat(response.convertedQuantity()).isEqualByComparingTo("167.017"); assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017"); @@ -64,7 +72,7 @@ class FbaPricingServiceTest { @Test @DisplayName("不包税CBM按配置系数折算且精度边界向上取整") void calculatesTaxExcludedCbmAtRoundingBoundary() { - FbaPricingRepository repository = readyRepository(); + readyRepository(); InboundPricingEntity inbound = inbound("363.001", "0.5000"); CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING"); CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00"); @@ -72,7 +80,7 @@ class FbaPricingServiceTest { when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule)); when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate)); - FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); assertThat(response.convertedQuantity()).isEqualByComparingTo("1.0001"); assertThat(response.chargeableQuantity()).isEqualByComparingTo("1.0001"); @@ -82,12 +90,12 @@ class FbaPricingServiceTest { @Test @DisplayName("审核未通过的测量快照禁止计费") void rejectsUnapprovedMeasurementSnapshot() { - FbaPricingRepository repository = readyRepository(); + readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); inbound.setMeasureReviewStatus("REJECTED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")) + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")) .isInstanceOf(BizException.class) .hasMessageContaining("审核通过"); verify(repository, never()).matchRules(any(), any()); @@ -96,25 +104,25 @@ class FbaPricingServiceTest { @Test @DisplayName("无客户规则和多客户规则都明确拒绝") void rejectsMissingAndAmbiguousRules() { - FbaPricingRepository missing = readyRepository(); + readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); - when(missing.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(missing.matchRules(inbound, NOW)).thenReturn(List.of()); - assertThatThrownBy(() -> service(missing).calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.matchRules(inbound, NOW)).thenReturn(List.of()); + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) .isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则"); - FbaPricingRepository ambiguous = readyRepository(); - when(ambiguous.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(ambiguous.matchRules(inbound, NOW)).thenReturn(List.of( + org.mockito.Mockito.reset(repository); + readyRepository(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.matchRules(inbound, NOW)).thenReturn(List.of( rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING"))); - assertThatThrownBy(() -> service(ambiguous).calculate(TENANT, OPERATOR, INBOUND, NOW, "many")) + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "many")) .isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则"); } @Test @DisplayName("同一幂等键重复试算直接返回原费用快照") void returnsOriginalSnapshotForRepeatedCalculation() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED")); @@ -122,7 +130,7 @@ class FbaPricingServiceTest { InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00")); when(repository.findBaseItem(TENANT, 501L)).thenReturn(item); - FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same"); + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same"); assertThat(response.feeId()).isEqualTo(501L); verify(repository, never()).matchRules(any(), any()); @@ -132,7 +140,6 @@ class FbaPricingServiceTest { @Test @DisplayName("重复发送只返回首次结果且不会重复写Outbox") void sendIsIdempotentAndDoesNotDuplicateOutbox() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); InboundPricingEntity inbound = inbound("10", "1"); InboundFeeEntity confirmed = savedFee("CONFIRMED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); @@ -148,7 +155,6 @@ class FbaPricingServiceTest { doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) .when(repository).insertIdempotency(any()); - FbaPricingService service = service(repository); service.send(TENANT, OPERATOR, INBOUND, "send-once"); service.send(TENANT, OPERATOR, INBOUND, "send-once"); @@ -159,12 +165,11 @@ class FbaPricingServiceTest { @Test @DisplayName("同一幂等键用于不同试算时点会冲突") void rejectsReusedIdempotencyKeyWithDifferentRequest() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L); completed.setRequestHash("different"); when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed); - assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same")) + assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same")) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.CONFLICT); @@ -173,7 +178,6 @@ class FbaPricingServiceTest { @Test @DisplayName("已发布费用可生成新版本且不回退已入库状态") void adjustsPublishedFeeWithoutRollingBackInbound() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); InboundPricingEntity inbound = inbound("10", "1"); inbound.setProcessStatus("INBOUNDED"); inbound.setFeeStatus("SENT"); @@ -195,7 +199,7 @@ class FbaPricingServiceTest { when(repository.completeIdempotency(any(), eq(502L), eq(NOW))).thenReturn(1); when(repository.findFee(TENANT, 502L)).thenAnswer(invocation -> inserted.get()); - FeeSnapshotResponse response = service(repository).adjustPublished(TENANT, OPERATOR, INBOUND, + FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1"); @@ -208,8 +212,7 @@ class FbaPricingServiceTest { verify(repository).insertOutbox(any()); } - private FbaPricingRepository readyRepository() { - FbaPricingRepository repository = mock(FbaPricingRepository.class); + private void readyRepository() { when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) .when(repository).insertIdempotency(any()); @@ -218,12 +221,14 @@ class FbaPricingServiceTest { when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1); when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any())).thenReturn(1); when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); - return repository; } - private FbaPricingService service(FbaPricingRepository repository) { - return new FbaPricingService(repository, - Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai"))); + @Configuration + static class TestConfig { + @Bean + Clock clock() { + return Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai")); + } } private InboundPricingEntity inbound(String weight, String volume) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java index 3a77a1533..330beec53 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java @@ -6,9 +6,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FulfillmentTaskQuery; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.repository.FulfillmentTaskRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; @@ -19,24 +22,24 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("履约任务服务") +@SpringJUnitConfig(FulfillmentTaskService.class) class FulfillmentTaskServiceTest { + @MockBean private FulfillmentTaskRepository repository; + @Resource private FulfillmentTaskService service; @Test @DisplayName("列表查询保留租户和分页条件") void shouldPreserveTenantAndPaginationInPageQuery() { - FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 1, 20); PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); when(repository.page(10001L, query)).thenReturn(expected); - assertThat(service(repository).page(10001L, query)).isSameAs(expected); + assertThat(service.page(10001L, query)).isSameAs(expected); verify(repository).page(10001L, query); } @Test @DisplayName("详情和按订单查询不存在时返回404") void shouldReturnNotFoundForMissingTaskAndOrder() { - FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); - FulfillmentTaskService service = service(repository); assertThatThrownBy(() -> service.detail(10001L, 1L)) .isInstanceOf(BizException.class) @@ -51,20 +54,14 @@ class FulfillmentTaskServiceTest { @Test @DisplayName("查询分配历史前先校验任务归属租户") void shouldVerifyTaskTenantBeforeLoadingAssignmentHistory() { - FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); FulfillmentTaskEntity task = new FulfillmentTaskEntity(); when(repository.findOwned(10001L, 1L)).thenReturn(task); PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); when(repository.pageHistory(10001L, 1L, 1, 20)).thenReturn(expected); - assertThat(service(repository).assignmentHistory(10001L, 1L, 1, 20)).isSameAs(expected); + assertThat(service.assignmentHistory(10001L, 1L, 1, 20)).isSameAs(expected); verify(repository).findOwned(10001L, 1L); } - private FulfillmentTaskService service(FulfillmentTaskRepository repository) { - FulfillmentTaskService service = new FulfillmentTaskService(); - ReflectionTestUtils.setField(service, "repository", repository); - return service; - } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java index 0585b4a89..a53b5d734 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java @@ -5,8 +5,11 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -19,7 +22,14 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("发送费用并入库状态编排") +@SpringJUnitConfig(InboundCompletionOrchestrator.class) class InboundCompletionOrchestratorTest { + @MockBean + private InboundWorkflowRepository repository; + @MockBean + private InboundIdempotencyService idempotency; + @Resource + private InboundCompletionOrchestrator service; @Test @DisplayName("审核通过且费用和库位引用已准备时进入INBOUNDING") void shouldBeginInboundAfterAllGuardsPass() { @@ -104,8 +114,6 @@ class InboundCompletionOrchestratorTest { } private Fixture fixture(String reviewStatus, boolean feeRelease) { - InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); - InboundIdempotencyService idempotency = mock(InboundIdempotencyService.class); when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) .thenReturn(new InboundIdempotencyService.Context(1L, false)); InboundOrderEntity order = new InboundOrderEntity(); @@ -117,7 +125,7 @@ class InboundCompletionOrchestratorTest { when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findConfirmedFee(10001L, 10L, 30L)).thenReturn(fee); when(repository.countPreparedAllocations(10001L, 10L)).thenReturn(1L); - return new Fixture(new InboundCompletionOrchestrator(repository, idempotency), repository, order); + return new Fixture(service, repository, order); } private record Fixture(InboundCompletionOrchestrator service, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java index 5e7d7629c..5c453b512 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundIdempotencyServiceTest.java @@ -4,9 +4,12 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundIdempotencyEntity; import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -17,16 +20,19 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("入库写操作幂等边界") +@SpringJUnitConfig(InboundIdempotencyService.class) class InboundIdempotencyServiceTest { + @MockBean + private InboundWorkflowRepository repository; + @Resource + private InboundIdempotencyService service; @Test @DisplayName("同键同请求成功记录可重放") void shouldReplayCompletedRequest() { - InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any()); InboundIdempotencyEntity existing = existing("SUCCESS"); when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1")) .thenReturn(existing); - InboundIdempotencyService service = new InboundIdempotencyService(repository); InboundIdempotencyService.Context context = service.begin( 10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|3|"); @@ -37,13 +43,11 @@ class InboundIdempotencyServiceTest { @Test @DisplayName("同键不同请求返回冲突") void shouldRejectDifferentRequestForSameKey() { - InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); doThrow(new DuplicateKeyException("duplicate")).when(repository).insertIdempotency(any()); InboundIdempotencyEntity existing = existing("SUCCESS"); existing.setRequestHash("different-hash"); when(repository.findIdempotency(10001L, "MEASURE_APPROVE", 9L, "key-1")) .thenReturn(existing); - InboundIdempotencyService service = new InboundIdempotencyService(repository); assertThatThrownBy(() -> service.begin( 10001L, 9L, 10L, "MEASURE_APPROVE", "key-1", "10|20|4|")) @@ -55,9 +59,7 @@ class InboundIdempotencyServiceTest { @Test @DisplayName("新请求完成时只允许PROCESSING条件更新一次") void shouldCompleteProcessingRecordOnce() { - InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); when(repository.completeIdempotency(1L, "{\"inboundId\":\"10\"}")).thenReturn(1); - InboundIdempotencyService service = new InboundIdempotencyService(repository); service.complete(new InboundIdempotencyService.Context(1L, false), 10L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java index 80c360a02..dd1852439 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementOpenApiServiceTest.java @@ -13,6 +13,8 @@ import com.baiyue.firstleg.security.AuthenticatedMeasurementApp; import com.baiyue.firstleg.security.MeasurementRequestAuthenticator; import com.baiyue.firstleg.security.MeasurementRequestHeaders; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import jakarta.validation.Validator; import jakarta.validation.Validation; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,6 +22,11 @@ import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.OffsetDateTime; import java.math.BigDecimal; @@ -41,6 +48,9 @@ import static org.mockito.Mockito.doThrow; import org.mockito.ArgumentCaptor; @DisplayName("第三方测量开放接口服务") +@SpringJUnitConfig(classes = {InboundMeasurementOpenApiService.class, + InboundMeasurementOpenApiServiceTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InboundMeasurementOpenApiServiceTest { private static final String BODY = """ {"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001", @@ -50,6 +60,14 @@ class InboundMeasurementOpenApiServiceTest { "boxSpecification":"50.2x40.1x30cm"} """; private static final byte[] IMAGE = "image-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + @MockBean private InboundMeasurementOpenApiRepository repository; + @MockBean private MeasurementRequestAuthenticator authenticator; + @MockBean private ThirdPartyCallLogService logs; + @MockBean private TransactionTemplate transactions; + @MockBean private OrderCargoClient orderCargo; + @MockBean private MeasurementImageClient images; + @Resource private ObjectMapper objectMapper; + @Resource private InboundMeasurementOpenApiService service; @Test @DisplayName("回调只写批次明细和幂等结果,不存在入库主单update入口") @@ -85,12 +103,11 @@ class InboundMeasurementOpenApiServiceTest { @DisplayName("重复Idempotency-Key且报文一致时返回首次结果,不重复写测量") void shouldReturnFirstResultForDuplicateCallback() throws Exception { Fixture fixture = fixture(); - ObjectMapper mapper = objectMapper(); EventResponse first = new EventResponse("M202608010001", "RK202608010001", true, "RECEIVED", OffsetDateTime.parse("2026-08-01T02:30:00Z")); ThirdPartyCallLogEntity existing = new ThirdPartyCallLogEntity(); existing.setRequestHash(requestHash()); - existing.setResultJson(mapper.writeValueAsString(first)); + existing.setResultJson(objectMapper.writeValueAsString(first)); existing.setProcessingStatus("ACCEPTED"); when(fixture.logs.claimIdempotency(1L)).thenReturn(false); when(fixture.logs.findIdempotencyOwner(10001L, "device-provider", "idem-1")).thenReturn(existing); @@ -137,12 +154,6 @@ class InboundMeasurementOpenApiServiceTest { } private Fixture fixture() { - InboundMeasurementOpenApiRepository repository = mock(InboundMeasurementOpenApiRepository.class); - MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); - ThirdPartyCallLogService logs = mock(ThirdPartyCallLogService.class); - TransactionTemplate transactions = mock(TransactionTemplate.class); - OrderCargoClient orderCargo = mock(OrderCargoClient.class); - MeasurementImageClient images = mock(MeasurementImageClient.class); when(logs.start(anyString(), anyString(), any(), anyString(), anyString())).thenReturn(1L); when(logs.claimIdempotency(1L)).thenReturn(true); when(authenticator.authenticate(anyString(), anyString(), any(), anyString(), anyBoolean())) @@ -161,14 +172,20 @@ class InboundMeasurementOpenApiServiceTest { TransactionCallback callback = invocation.getArgument(0); return callback.doInTransaction(mock(TransactionStatus.class)); }); - InboundMeasurementOpenApiService service = new InboundMeasurementOpenApiService(repository, - authenticator, logs, orderCargo, images, "internal-token", objectMapper(), - Validation.buildDefaultValidatorFactory().getValidator(), transactions); return new Fixture(repository, logs, orderCargo, images, service); } - private ObjectMapper objectMapper() { - return new ObjectMapper().findAndRegisterModules(); + @Configuration + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + + @Bean + Validator validator() { + return Validation.buildDefaultValidatorFactory().getValidator(); + } } private InboundOrderLookupEntity inbound() { 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 d3ac61d4d..7f4afb397 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -9,10 +9,13 @@ import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.entity.InboundWeightRevisionEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.util.List; @@ -26,15 +29,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("平台入库测量应用服务") +@SpringJUnitConfig(InboundMeasurementService.class) class InboundMeasurementServiceTest { - private InboundMeasurementRepository repository; - private InboundMeasurementService service; - - @BeforeEach - void setUp() { - repository = mock(InboundMeasurementRepository.class); - service = new InboundMeasurementService(repository, mock(MeasurementBatchService.class)); - } + @MockBean private InboundMeasurementRepository repository; + @MockBean private MeasurementBatchService batchService; + @Resource private InboundMeasurementService service; @Test void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java index fd015b9af..d168fe3f8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java @@ -9,7 +9,10 @@ import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderSummary; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundOrderRepository; +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.time.LocalDateTime; import java.util.List; @@ -20,15 +23,20 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(InboundOrderQueryService.class) class InboundOrderQueryServiceTest { + @MockBean + private InboundOrderRepository repository; + @Resource + private InboundOrderQueryService service; + @Test void shouldQueryAllPlatformTenantsWhenTenantFilterIsMissing() { - InboundOrderRepository repository = mock(InboundOrderRepository.class); InboundOrderQuery query = query(null, null, null, null); PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); when(repository.page(null, query)).thenReturn(expected); - assertThat(new InboundOrderQueryService(repository).page(null, query)).isSameAs(expected); + assertThat(service.page(null, query)).isSameAs(expected); verify(repository).page(null, query); } @@ -46,9 +54,8 @@ class InboundOrderQueryServiceTest { @Test void shouldRejectConflictingTabAndStatus() { - InboundOrderRepository repository = mock(InboundOrderRepository.class); InboundOrderQuery query = query("WAIT_MEASURE", InboundListTab.WAIT_MEASURE, null, null); - assertThatThrownBy(() -> new InboundOrderQueryService(repository).page(10001L, query)) + assertThatThrownBy(() -> service.page(10001L, query)) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.BAD_REQUEST); @@ -56,31 +63,28 @@ class InboundOrderQueryServiceTest { @Test void shouldRejectReversedArrivalRange() { - InboundOrderRepository repository = mock(InboundOrderRepository.class); LocalDateTime now = LocalDateTime.now(); - assertThatThrownBy(() -> new InboundOrderQueryService(repository) - .page(10001L, query(null, null, now, now.minusDays(1)))) + assertThatThrownBy(() -> service.page( + 10001L, query(null, null, now, now.minusDays(1)))) .isInstanceOf(BizException.class); } @Test void shouldLoadDetailOnlyAfterTenantOwnedOrderExists() { - InboundOrderRepository repository = mock(InboundOrderRepository.class); InboundOrderEntity order = new InboundOrderEntity(); order.setId(9L); InboundOrderDetail detail = new InboundOrderDetail(null, null, List.of(), List.of(), List.of(), List.of()); when(repository.findOwned(10001L, 9L)).thenReturn(order); when(repository.detail(10001L, order)).thenReturn(detail); - assertThat(new InboundOrderQueryService(repository).detail(10001L, 9L)).isSameAs(detail); + assertThat(service.detail(10001L, 9L)).isSameAs(detail); verify(repository).findOwned(10001L, 9L); verify(repository).detail(10001L, order); } @Test void shouldReturnBusinessNotFoundForCrossTenantOrMissingOrder() { - InboundOrderRepository repository = mock(InboundOrderRepository.class); - assertThatThrownBy(() -> new InboundOrderQueryService(repository).detail(10002L, 9L)) + assertThatThrownBy(() -> service.detail(10002L, 9L)) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.NOT_FOUND); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index 808940ff0..7e4e16e21 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -6,10 +6,13 @@ import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -24,14 +27,13 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("测量批次共享领域服务") +@SpringJUnitConfig(MeasurementBatchService.class) class MeasurementBatchServiceTest { - private InboundMeasurementRepository repository; - private MeasurementBatchService service; + @MockBean private InboundMeasurementRepository repository; + @Resource private MeasurementBatchService service; @BeforeEach void setUp() { - repository = mock(InboundMeasurementRepository.class); - service = new MeasurementBatchService(repository); when(repository.findInbound(1L, 10L)).thenReturn(inbound()); org.mockito.Mockito.doAnswer(invocation -> { InboundMeasurementEntity entity = invocation.getArgument(0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index 37840ad8b..1ebb61e52 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -6,8 +6,14 @@ import com.baiyue.firstleg.config.InboundProperties; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.util.List; @@ -19,6 +25,9 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(classes = {MeasurementLocationOccupationService.class, + MeasurementLocationOccupationServiceTest.TestConfig.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MeasurementLocationOccupationServiceTest { private static final long BUSINESS_TENANT = 10001L; private static final long RESOURCE_TENANT = 90001L; @@ -27,12 +36,11 @@ class MeasurementLocationOccupationServiceTest { private static final long OPERATOR = 9L; private static final long MEASUREMENT = 30L; - private final WarehousePalletLocationClient warehouse = mock(WarehousePalletLocationClient.class); - private final InboundPalletAllocationRepository allocations = mock(InboundPalletAllocationRepository.class); - private final InboundWorkflowRepository workflow = mock(InboundWorkflowRepository.class); - private final InboundProperties properties = properties(); - private final MeasurementLocationOccupationService service = - new MeasurementLocationOccupationService(warehouse, allocations, workflow, properties, "internal-token"); + @MockBean private WarehousePalletLocationClient warehouse; + @MockBean private InboundPalletAllocationRepository allocations; + @MockBean private InboundWorkflowRepository workflow; + @Resource private InboundProperties properties; + @Resource private MeasurementLocationOccupationService service; @Test void sharedWarehouseResourcesUseResourceTenantWhileSnapshotsRemainInBusinessTenant() { @@ -119,6 +127,14 @@ class MeasurementLocationOccupationServiceTest { return value; } + @Configuration + static class TestConfig { + @Bean + InboundProperties inboundProperties() { + return properties(); + } + } + private static InboundOrderEntity inbound() { InboundOrderEntity value = new InboundOrderEntity(); value.setTenantId(BUSINESS_TENANT); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index c3d725a9f..374895c7b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -8,8 +8,11 @@ import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundWorkflowRepository; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; import java.util.concurrent.Callable; @@ -31,7 +34,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("入库测量审核服务") +@SpringJUnitConfig(MeasurementReviewService.class) class MeasurementReviewServiceTest { + @MockBean private InboundWorkflowRepository repository; + @MockBean private InboundIdempotencyService idempotency; + @MockBean private InboundProperties properties; + @Resource private MeasurementReviewService service; @Test @DisplayName("审核通过是测量到计费的唯一正常推进关口") void shouldApproveOnlyPendingSubmittedMeasurement() { @@ -138,12 +146,9 @@ class MeasurementReviewServiceTest { } private Fixture fixture(boolean allowRelease) { - InboundWorkflowRepository repository = mock(InboundWorkflowRepository.class); - InboundIdempotencyService idempotency = mock(InboundIdempotencyService.class); when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) .thenReturn(new InboundIdempotencyService.Context(1L, false)); - InboundProperties properties = new InboundProperties(); - properties.setAllowRejectedMeasurement(allowRelease); + when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease); InboundOrderEntity order = new InboundOrderEntity(); order.setProcessStatus("MEASURE_PENDING"); order.setMeasureReviewStatus("PENDING"); @@ -151,8 +156,7 @@ class MeasurementReviewServiceTest { measurement.setStatus("SUBMITTED"); when(repository.findOwned(10001L, 10L)).thenReturn(order); when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement); - return new Fixture(new MeasurementReviewService(repository, idempotency, properties), - repository, idempotency, order, measurement); + return new Fixture(service, repository, idempotency, order, measurement); } private record Fixture(MeasurementReviewService service, InboundWorkflowRepository repository, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java index f9d2f0756..9bf0ca80f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java @@ -2,7 +2,10 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; +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 org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; @@ -12,13 +15,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(MeasurementSubmissionService.class) class MeasurementSubmissionServiceTest { - private final InboundMeasurementService measurements = mock(InboundMeasurementService.class); - private final MeasurementLocationOccupationService locations = mock(MeasurementLocationOccupationService.class); - private final MeasurementSubmissionService service = new MeasurementSubmissionService(measurements, locations); + @MockBean private InboundMeasurementService measurements; + @MockBean private MeasurementLocationOccupationService locations; + @Resource private MeasurementSubmissionService service; @Test - void 测量提交与库位占用处于同一事务边界() throws Exception { + void shouldHandleCase01() throws Exception { assertThat(MeasurementSubmissionService.class .getDeclaredMethod("submit", long.class, long.class, long.class, InboundMeasurementRequests.SubmitRequest.class) @@ -26,7 +30,7 @@ class MeasurementSubmissionServiceTest { } @Test - void 测量完成后立即占用库位再返回待客服审核() { + void shouldHandleCase02() { var request = new InboundMeasurementRequests.SubmitRequest(30L, 2); var submitted = new InboundMeasurementResponses.MeasurementSubmitted( 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); @@ -38,7 +42,7 @@ class MeasurementSubmissionServiceTest { } @Test - void 库位占用失败向上抛出以触发测量事务回滚() { + void shouldHandleCase03() { var request = new InboundMeasurementRequests.SubmitRequest(30L, 2); var submitted = new InboundMeasurementResponses.MeasurementSubmitted( 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java index 5d7b19af6..769cc32dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java @@ -7,7 +7,11 @@ import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.client.MeasurementImageClient; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundOrderRepository; +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; @@ -19,16 +23,17 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(MerchantInboundBillingService.class) +@TestPropertySource(properties = "baiyue.internal.token=internal-token") class MerchantInboundBillingServiceTest { - private final InboundOrderRepository orders = mock(InboundOrderRepository.class); - private final FbaPricingService pricing = mock(FbaPricingService.class); - private final InboundMeasurementService measurements = mock(InboundMeasurementService.class); - private final MeasurementImageClient imageClient = mock(MeasurementImageClient.class); - private final MerchantInboundBillingService service = new MerchantInboundBillingService( - orders, pricing, measurements, imageClient, "internal-token"); + @MockBean private InboundOrderRepository orders; + @MockBean private FbaPricingService pricing; + @MockBean private InboundMeasurementService measurements; + @MockBean private MeasurementImageClient imageClient; + @Resource private MerchantInboundBillingService service; @Test - void 客户只能看到本租户已完成入库且已发布的费用() { + void shouldHandleCase01() { InboundOrderEntity inbound = inbound("INBOUNDED", "SENT"); InboundOrderDetail detail = new InboundOrderDetail(mock(com.baiyue.firstleg.dto.InboundOrderSummary.class), "客户备注", List.of(), List.of( @@ -64,7 +69,7 @@ class MerchantInboundBillingServiceTest { } @Test - void 客服尚未完成发布时客户不可见中间费用() { + void shouldHandleCase02() { InboundOrderEntity inbound = inbound("FEE_CONFIRMING", "CONFIRMED"); when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); @@ -75,7 +80,7 @@ class MerchantInboundBillingServiceTest { } @Test - void 使用租户条件查询防止跨租户读取订单费用() { + void shouldHandleCase03() { when(orders.findByOrderId(20002L, 77L)).thenReturn(null); assertThatThrownBy(() -> service.find(20002L, 77L)).isInstanceOf(BizException.class); @@ -85,7 +90,7 @@ class MerchantInboundBillingServiceTest { } @Test - void 只能下载已发布计费明细实际引用的图片() { + void shouldHandleCase04() { InboundOrderEntity inbound = inbound("INBOUNDED", "SENT"); InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); InboundMeasurementResponses.Item item = mock(InboundMeasurementResponses.Item.class); @@ -94,7 +99,9 @@ class MerchantInboundBillingServiceTest { when(measurement.weightResults()).thenReturn(List.of()); when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); when(measurements.detail(10001L, 88L)).thenReturn(measurement); - when(imageClient.download("internal-token", 10001L, 88L, 41L)).thenReturn( + when(imageClient.download(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(88L), + org.mockito.ArgumentMatchers.eq(41L))).thenReturn( org.springframework.http.ResponseEntity.ok() .contentType(org.springframework.http.MediaType.IMAGE_PNG) .body(new byte[]{1, 2, 3})); @@ -106,7 +113,7 @@ class MerchantInboundBillingServiceTest { } @Test - void 拒绝下载未被当前计费明细引用的图片() { + void shouldHandleCase05() { InboundOrderEntity inbound = inbound("INBOUNDED", "SENT"); InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); when(measurement.items()).thenReturn(List.of()); diff --git a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java index 82ceca7a1..96a594ea1 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java @@ -1,5 +1,7 @@ package com.baiyue.gateway.config; +import jakarta.annotation.Resource; + import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.http.HttpStatus; @@ -15,17 +17,9 @@ import reactor.core.publisher.Mono; */ @Component public class RedisTokenSessionWebFilter implements WebFilter { - private final ReactiveStringRedisTemplate redisTemplate; - private final GatewaySecurityProperties properties; - private final ObjectMapper objectMapper; - - public RedisTokenSessionWebFilter(ReactiveStringRedisTemplate redisTemplate, - GatewaySecurityProperties properties, - ObjectMapper objectMapper) { - this.redisTemplate = redisTemplate; - this.properties = properties; - this.objectMapper = objectMapper; - } + @Resource private ReactiveStringRedisTemplate reactiveStringRedisTemplate; + @Resource private GatewaySecurityProperties properties; + @Resource private ObjectMapper objectMapper; @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { @@ -40,7 +34,7 @@ public class RedisTokenSessionWebFilter implements WebFilter { WebFilterChain chain) { String jwtId = jwt.getToken().getId(); if (jwtId == null || jwtId.isBlank()) return unauthorized(exchange); - return redisTemplate.hasKey(properties.accessTokenKey(jwtId)) + return reactiveStringRedisTemplate.hasKey(properties.accessTokenKey(jwtId)) .onErrorReturn(false) .flatMap(active -> Boolean.TRUE.equals(active) ? chain.filter(exchange) : unauthorized(exchange)); diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 31673a42a..9faae6430 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -57,7 +57,7 @@ spring: - id: warehouse-service uri: lb://warehouse-service predicates: - - Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**,/api/platform/v1/partners/**,/api/platform/v1/express-companies/**,/api/platform/v1/pallets/**,/api/platform/v1/locations/** + - Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**,/api/platform/v1/partners/**,/api/platform/v1/express-companies/**,/api/platform/v1/pallets/**,/api/platform/v1/locations/**,/api/platform/v1/overseas-storage-fees/** - id: file-service uri: lb://file-service predicates: @@ -216,6 +216,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index 2ce509a57..750c7bb5b 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -48,7 +48,8 @@ class GatewayFileRouteConfigurationTest { String paths = String.join(",", predicate.getArgs().values()); assertThat(paths) .contains("/api/platform/v1/pallets/**") - .contains("/api/platform/v1/locations/**"); + .contains("/api/platform/v1/locations/**") + .contains("/api/platform/v1/overseas-storage-fees/**"); }); } } diff --git a/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java index d7662ec18..5aaf44a83 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/Phase2InboundRouteContractTest.java @@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat; class Phase2InboundRouteContractTest { @Test - void 网关覆盖平台入库计费客户端费用和测量图片路径() throws Exception { + void shouldHandleCase01() throws Exception { String yaml = new ClassPathResource("application.yml") .getContentAsString(StandardCharsets.UTF_8); assertThat(yaml) diff --git a/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java b/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java index 8e7eec071..f3152b3c6 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/CustomerListExportProviderController.java @@ -6,6 +6,8 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; import com.baiyue.iam.service.CustomerListExportProviderService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -20,14 +22,15 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/exports/v1/CUSTOMER_LIST") public class CustomerListExportProviderController { - private final CustomerListExportProviderService service; - private final byte[] internalToken; + @Resource + private CustomerListExportProviderService service; + @Value("${baiyue.security.internal-service-token}") + private String internalTokenValue; + private byte[] internalToken; - public CustomerListExportProviderController( - CustomerListExportProviderService service, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.service = service; - this.internalToken = internalToken.getBytes(StandardCharsets.UTF_8); + @PostConstruct + void initializeToken() { + this.internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); } @PostMapping("/batches") diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java index c3b7c8e37..8ec2775ac 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformUserManagementController.java @@ -9,6 +9,7 @@ import com.baiyue.iam.service.TenantGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.annotation.Resource; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Pattern; @@ -23,11 +24,8 @@ import org.springframework.web.bind.annotation.*; @Validated @Tag(name = "Platform user administration / 平台用户管理") public class PlatformUserManagementController { - private final PlatformUserManagementService service; - - public PlatformUserManagementController(PlatformUserManagementService service) { - this.service = service; - } + @Resource + private PlatformUserManagementService service; @GetMapping @PreAuthorize("hasAuthority('platform:user:list')") diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java index aaceaabb4..97360fc86 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java @@ -3,6 +3,7 @@ package com.baiyue.iam.repository; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.mapper.IamTenantMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; @@ -11,11 +12,8 @@ import java.util.List; /** Read-only customer queries used by the generic export provider. */ @Repository public class CustomerListExportProviderRepository { - private final IamTenantMapper tenantMapper; - - public CustomerListExportProviderRepository(IamTenantMapper tenantMapper) { - this.tenantMapper = tenantMapper; - } + @Resource + private IamTenantMapper tenantMapper; public long maximumId(String keyword, String status) { LambdaQueryWrapper wrapper = filters(keyword, status) diff --git a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java index f17e7f99c..2d64b692b 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/MemberRepository.java @@ -8,6 +8,7 @@ import com.baiyue.iam.mapper.IamMembershipMapper; import com.baiyue.iam.mapper.IamMembershipRoleMapper; import com.baiyue.iam.mapper.IamUserMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; @@ -18,16 +19,12 @@ import java.util.List; /** 企业成员仓储,所有成员查询均显式携带租户条件。 */ @Repository public class MemberRepository { - private final IamMembershipMapper membershipMapper; - private final IamUserMapper userMapper; - private final IamMembershipRoleMapper roleMapper; - - public MemberRepository(IamMembershipMapper membershipMapper, IamUserMapper userMapper, - IamMembershipRoleMapper roleMapper) { - this.membershipMapper = membershipMapper; - this.userMapper = userMapper; - this.roleMapper = roleMapper; - } + @Resource + private IamMembershipMapper membershipMapper; + @Resource + private IamUserMapper userMapper; + @Resource + private IamMembershipRoleMapper roleMapper; public List findByTenant(long tenantId) { return membershipMapper.selectList(new LambdaQueryWrapper() diff --git a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java index 1a4f40fdd..5fc245647 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java @@ -7,6 +7,7 @@ import com.baiyue.iam.mapper.IamTenantAddressMapper; import com.baiyue.iam.mapper.IamTenantQualificationMapper; import com.baiyue.iam.mapper.IamUserMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.util.List; @@ -16,16 +17,12 @@ import java.util.List; */ @Repository public class ProfileResourceRepository { - private final IamUserMapper userMapper; - private final IamTenantAddressMapper addressMapper; - private final IamTenantQualificationMapper qualificationMapper; - - public ProfileResourceRepository(IamUserMapper userMapper, IamTenantAddressMapper addressMapper, - IamTenantQualificationMapper qualificationMapper) { - this.userMapper = userMapper; - this.addressMapper = addressMapper; - this.qualificationMapper = qualificationMapper; - } + @Resource + private IamUserMapper userMapper; + @Resource + private IamTenantAddressMapper addressMapper; + @Resource + private IamTenantQualificationMapper qualificationMapper; public IamUser findUserByAuthId(Long authUserId) { return userMapper.selectOne(new LambdaQueryWrapper().eq(IamUser::getAuthUserId, authUserId).last("LIMIT 1")); } public void saveUser(IamUser user) { userMapper.updateById(user); } diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index 3222ba497..76c8b4fad 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -3,6 +3,7 @@ package com.baiyue.iam.repository; import com.baiyue.iam.entity.*; import com.baiyue.iam.mapper.*; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -13,22 +14,16 @@ import java.util.Set; /** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */ @Repository public class RbacRepository { - private final IamRoleMapper roleMapper; - private final IamPermissionMapper permissionMapper; - private final IamMembershipRoleMapper membershipRoleMapper; - private final IamRolePermissionMapper rolePermissionMapper; - private final IamDataScopeMapper dataScopeMapper; - - public RbacRepository(IamRoleMapper roleMapper, IamPermissionMapper permissionMapper, - IamMembershipRoleMapper membershipRoleMapper, - IamRolePermissionMapper rolePermissionMapper, - IamDataScopeMapper dataScopeMapper) { - this.roleMapper = roleMapper; - this.permissionMapper = permissionMapper; - this.membershipRoleMapper = membershipRoleMapper; - this.rolePermissionMapper = rolePermissionMapper; - this.dataScopeMapper = dataScopeMapper; - } + @Resource + private IamRoleMapper roleMapper; + @Resource + private IamPermissionMapper permissionMapper; + @Resource + private IamMembershipRoleMapper membershipRoleMapper; + @Resource + private IamRolePermissionMapper rolePermissionMapper; + @Resource + private IamDataScopeMapper dataScopeMapper; public IamRole findRole(Long id, long tenantId, String appCode) { return roleMapper.selectOne(new LambdaQueryWrapper() diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index c02d0a010..aea4b7c84 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -6,6 +6,7 @@ import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; @@ -17,13 +18,10 @@ import java.util.List; */ @Repository public class TenantRepository { - private final IamTenantMapper tenantMapper; - private final IamTenantReviewMapper reviewMapper; - - public TenantRepository(IamTenantMapper tenantMapper, IamTenantReviewMapper reviewMapper) { - this.tenantMapper = tenantMapper; - this.reviewMapper = reviewMapper; - } + @Resource + private IamTenantMapper tenantMapper; + @Resource + private IamTenantReviewMapper reviewMapper; public boolean existsByCreditCode(String creditCode) { return tenantMapper.selectCount(new LambdaQueryWrapper() diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java index 1589488e2..dce0133f6 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java @@ -8,6 +8,8 @@ import com.baiyue.common.core.export.ExportContracts.ExportColumn; import com.baiyue.common.core.export.ExportContracts.ExportColumnType; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.CustomerListExportProviderRepository; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -54,13 +56,14 @@ public class CustomerListExportProviderService { column("approvedAt", "审批时间", ExportColumnType.DATETIME, 21)); private static final String SCHEMA_HASH = schemaHash(COLUMNS); - private final CustomerListExportProviderRepository repository; - private final byte[] snapshotSigningKey; + @Resource + private CustomerListExportProviderRepository repository; + @Value("${baiyue.security.internal-service-token}") + private String internalServiceToken; + private byte[] snapshotSigningKey; - public CustomerListExportProviderService( - CustomerListExportProviderRepository repository, - @Value("${baiyue.security.internal-service-token}") String internalServiceToken) { - this.repository = repository; + @PostConstruct + void initializeSigningKey() { this.snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8); } diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 80d582e35..8821e8d52 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -13,6 +13,7 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,18 +30,14 @@ import org.slf4j.LoggerFactory; public class MemberService { private static final String GRANT_ROLE_PERMISSION = "merchant:member:grant-role"; private static final Logger log = LoggerFactory.getLogger(MemberService.class); - private final MemberRepository repository; - private final IamRoleMapper roleMapper; - private final AuthAccountClient authClient; - private final String internalToken; - - public MemberService(MemberRepository repository, IamRoleMapper roleMapper, AuthAccountClient authClient, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.repository = repository; - this.roleMapper = roleMapper; - this.authClient = authClient; - this.internalToken = internalToken; - } + @Resource + private MemberRepository repository; + @Resource + private IamRoleMapper roleMapper; + @Resource + private AuthAccountClient authClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; public List list(long tenantId) { return repository.findByTenant(tenantId).stream().map(m -> response(tenantId, m)).toList(); diff --git a/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java index 0dc55e438..0bd005cad 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java @@ -7,6 +7,7 @@ import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.repository.MemberRepository; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.baiyue.common.audit.BusinessAudit; @@ -14,8 +15,8 @@ import com.baiyue.common.audit.BusinessAudit; /** 当前用户个人资料服务,必须同时匹配认证账号与当前租户成员关系。 */ @Service public class PersonalProfileService { - private final MemberRepository repository; - public PersonalProfileService(MemberRepository repository) { this.repository = repository; } + @Resource + private MemberRepository repository; public PersonalProfileResponse get(long tenantId, Long authUserId, String username) { return response(tenantId, authUserId, username); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 4ecc46595..7780a8c84 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -13,6 +13,7 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; +import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -31,19 +32,14 @@ public class PlatformInternalUserService { private static final long PLATFORM_TENANT_ID = 0L; private static final Logger log = LoggerFactory.getLogger(PlatformInternalUserService.class); - private final MemberRepository repository; - private final IamRoleMapper roleMapper; - private final AuthAccountClient authClient; - private final String internalToken; - - public PlatformInternalUserService(MemberRepository repository, IamRoleMapper roleMapper, - AuthAccountClient authClient, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.repository = repository; - this.roleMapper = roleMapper; - this.authClient = authClient; - this.internalToken = internalToken; - } + @Resource + private MemberRepository repository; + @Resource + private IamRoleMapper roleMapper; + @Resource + private AuthAccountClient authClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; @Transactional(readOnly = true) public List list() { diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 2ae33bedc..15543d033 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -15,6 +15,7 @@ import com.baiyue.iam.repository.MemberRepository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; +import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -36,19 +37,14 @@ public class PlatformUserManagementService { private static final long PLATFORM_TENANT_ID = 0L; private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class); - private final MemberRepository members; - private final IamRoleMapper roleMapper; - private final AuthAccountClient authClient; - private final String internalToken; - - public PlatformUserManagementService(MemberRepository members, IamRoleMapper roleMapper, - AuthAccountClient authClient, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.members = members; - this.roleMapper = roleMapper; - this.authClient = authClient; - this.internalToken = internalToken; - } + @Resource + private MemberRepository members; + @Resource + private IamRoleMapper roleMapper; + @Resource + private AuthAccountClient authClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; @Transactional(readOnly = true) public PageResult page(long page, long size, String keyword, String status) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 8e85371ff..56eb0053d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -13,9 +13,9 @@ import com.baiyue.iam.repository.MemberRepository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; +import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Service; @@ -37,28 +37,15 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private static final int USERNAME_SUFFIX_LENGTH = 8; private static final int USERNAME_ATTEMPTS = 3; - private final MemberRepository members; - private final IamRoleMapper roles; - private final AuthAccountClient authClient; - private final String internalToken; - private final SecureRandom random; - - @Autowired - public TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles, - AuthAccountClient authClient, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this(members, roles, authClient, internalToken, new SecureRandom()); - } - - TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles, - AuthAccountClient authClient, String internalToken, - SecureRandom random) { - this.members = members; - this.roles = roles; - this.authClient = authClient; - this.internalToken = internalToken; - this.random = random; - } + @Resource + private MemberRepository members; + @Resource + private IamRoleMapper roles; + @Resource + private AuthAccountClient authClient; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private SecureRandom random = new SecureRandom(); @Override public String name() { return "tenant-administrator"; } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java b/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java index bca36e43b..279906448 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinator.java @@ -8,6 +8,8 @@ import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import java.util.ArrayList; import java.util.Collections; @@ -21,9 +23,11 @@ import java.util.concurrent.atomic.AtomicBoolean; @Service public class TenantApprovalInitializationCoordinator { private static final Logger log = LoggerFactory.getLogger(TenantApprovalInitializationCoordinator.class); - private final List initializers; + @Resource + private List initializers; - public TenantApprovalInitializationCoordinator(List initializers) { + @PostConstruct + void initialize() { List ordered = new ArrayList<>(initializers); AnnotationAwareOrderComparator.sort(ordered); Set names = new HashSet<>(); diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index bdd188882..a77bec0d0 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -38,6 +38,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java index b2b01833f..5da6f3e88 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java @@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; class AuthorizationControllerContractTest { @Test - void 当前权限接口不应依赖角色管理权限() throws Exception { + void shouldHandleCase01() throws Exception { Method method = AuthorizationController.class.getMethod("permissions", String.class, AuthenticatedUser.class); diff --git a/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java b/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java index 86ee0e330..6b24092b8 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/CustomerListExportProviderControllerTest.java @@ -6,6 +6,10 @@ import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; import com.baiyue.iam.service.CustomerListExportProviderService; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +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.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,7 +22,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(CustomerListExportProviderController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class CustomerListExportProviderControllerTest { + @MockBean private CustomerListExportProviderService service; + @Resource private CustomerListExportProviderController controller; @Test void exposesTheGenericCustomerListProviderPath() throws Exception { assertEquals("/internal/exports/v1/CUSTOMER_LIST", @@ -30,9 +38,6 @@ class CustomerListExportProviderControllerTest { @Test void requiresInternalTokenBeforeCallingProvider() { - CustomerListExportProviderService service = mock(CustomerListExportProviderService.class); - CustomerListExportProviderController controller = - new CustomerListExportProviderController(service, "internal-token"); ExportBatchRequest request = request(); BizException missing = assertThrows(BizException.class, () -> controller.batch(null, request)); diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index bf00637ab..974474e81 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class IamOpenApiContractTest { @Test - void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException { + void shouldHandleCase01() throws IOException { String document; try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) { assertThat(stream).isNotNull(); diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java index d8a786482..c58e98fe9 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -7,26 +7,28 @@ import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(InternalTenantController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalCustomerExportContractTest { + @MockBean private MerchantOnboardingService onboardingService; + @MockBean private ProfileResourceService profileResourceService; + @MockBean private TenantService service; + @Resource private InternalTenantController controller; @Test void rejectsMissingInternalTokenAndAcceptsConfiguredToken() { - TenantService service = mock(TenantService.class); - InternalTenantController controller = new InternalTenantController(); - ReflectionTestUtils.setField(controller, "onboardingService", mock(MerchantOnboardingService.class)); - ReflectionTestUtils.setField(controller, "profileResourceService", mock(ProfileResourceService.class)); - ReflectionTestUtils.setField(controller, "tenantService", service); - ReflectionTestUtils.setField(controller, "internalTokenValue", "internal-token"); - controller.initializeToken(); BizException denied = assertThrows(BizException.class, () -> controller.exportCustomers(null, null, 100, null, null)); diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java index b0202fa5d..c80c17374 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformCustomerResourceContractTest.java @@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; class PlatformCustomerResourceContractTest { @Test - void 平台地址和资质只读查询统一使用客户查看权限() throws Exception { + void shouldHandleCase01() throws Exception { assertReadOnlyViewContract("platformAddresses", "/api/platform/v1/iam/tenants/{tenantId}/addresses"); assertReadOnlyViewContract("platformQualifications", diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java index 3b528e33b..3d03e75a7 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*; class PlatformInternalUserContractTest { @Test - void 内部用户列表必须校验列表权限() throws Exception { + void shouldHandleCase01() throws Exception { Method method = ProfileResourceController.class.getMethod("internalUsers", com.baiyue.common.security.AuthenticatedUser.class); @@ -24,7 +24,7 @@ class PlatformInternalUserContractTest { } @Test - void 平台用户写接口以成员关系主键寻址() { + void shouldHandleCase02() { assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}"); assertMapping("changeInternalUserStatus", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status"); diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 4ee62b7ad..b5e407a1f 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -21,7 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; class PlatformTenantControllerContractTest { @Test - void 客户分页接口声明权限分页约束和错误状态() throws Exception { + void shouldHandleCase01() throws Exception { Method method = PlatformTenantController.class.getMethod("page", long.class, long.class, String.class, String.class, AuthenticatedUser.class); @@ -41,7 +41,7 @@ class PlatformTenantControllerContractTest { } @Test - void 待审列表必须校验客户审核权限() throws Exception { + void shouldHandleCase02() throws Exception { Method method = PlatformTenantController.class.getMethod("reviews", AuthenticatedUser.class); assertEquals("hasAuthority('platform:customer:review')", @@ -49,7 +49,7 @@ class PlatformTenantControllerContractTest { } @Test - void 客户详情必须使用独立查看权限() throws Exception { + void shouldHandleCase03() throws Exception { Method method = PlatformTenantController.class.getMethod("tenant", long.class, AuthenticatedUser.class); assertEquals("hasAuthority('platform:customer:view')", @@ -57,7 +57,7 @@ class PlatformTenantControllerContractTest { } @Test - void 审核通过接口返回一次性管理员凭据结果() throws Exception { + void shouldHandleCase04() throws Exception { Method method = PlatformTenantController.class.getMethod("approve", long.class, com.baiyue.iam.dto.TenantDtos.ReviewRequest.class, AuthenticatedUser.class); diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java index 489358cac..e97c8f356 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java @@ -9,6 +9,9 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.junit.jupiter.api.Test; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; @@ -18,15 +21,15 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(CustomerListExportProviderRepository.class) class CustomerListExportProviderRepositoryTest { + @MockBean private IamTenantMapper mapper; + @Resource private CustomerListExportProviderRepository repository; @Test void batchQueryAlwaysConstrainsMerchantSnapshotAndAscendingCursor() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), IamTenant.class); - IamTenantMapper mapper = mock(IamTenantMapper.class); when(mapper.selectList(any())).thenReturn(List.of()); - CustomerListExportProviderRepository repository = - new CustomerListExportProviderRepository(mapper); repository.findBatch(100L, 200L, 501, "柏悦", "ACTIVE"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java index 102de9726..287f132c7 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerProfileMigrationContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class CustomerProfileMigrationContractTest { @Test - void 企业审核初始化按角色自然键为全局管理员模板授权() throws IOException { + void shouldHandleCase01() throws IOException { String sql; try (var stream = getClass().getResourceAsStream("/db/phase1-onboarding.sql")) { assertThat(stream).isNotNull(); @@ -26,7 +26,7 @@ class CustomerProfileMigrationContractTest { } @Test - void 客户档案迁移覆盖全部字段并通过元数据判断保持可重复执行() throws IOException { + void shouldHandleCase02() throws IOException { String sql; try (var stream = getClass().getResourceAsStream("/db/phase1-customer-profile.sql")) { assertThat(stream).isNotNull(); @@ -49,7 +49,7 @@ class CustomerProfileMigrationContractTest { } @Test - void 统一测试环境入口包含客户字段查看权限和执行后核验() throws IOException { + void shouldHandleCase03() throws IOException { Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", "..")); Path migration = repositoryRoot.resolve("deploy/sql/2026-07-31一期回归测试环境增量迁移.sql"); Path readme = repositoryRoot.resolve("deploy/sql/README.md"); @@ -85,8 +85,8 @@ class CustomerProfileMigrationContractTest { .contains("phase1-platform-master-data-and-permission-management.sql") .contains("phase1-customer-profile.sql") .contains("phase1-onboarding.sql") - .contains("IAM 注册审核字段") + .contains("`PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段") .contains("全局 `MERCHANT_OWNER` 角色模板") - .contains("测试环境只执行统一入口"); + .contains("测试环境只执行当前固定入口"); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index b878d69a8..f6ae54403 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -6,27 +6,31 @@ import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.mapper.AuthorizationQueryMapper; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.util.List; 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; +@SpringJUnitConfig(AuthorizationSnapshotService.class) class AuthorizationSnapshotServiceTest { + @MockBean private AuthorizationQueryMapper mapper; + @Resource private AuthorizationSnapshotService service; @Test void loadsRolesAndPermissionsForActiveMerchantIdentity() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) .thenReturn(List.of("ADMIN", "OPERATOR")); when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) .thenReturn(List.of("order:read", "order:create")); - var result = service(mapper) + var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); assertThat(result.getAppCode()).isEqualTo("MERCHANT"); @@ -37,9 +41,7 @@ class AuthorizationSnapshotServiceTest { @Test void rejectsPlatformIdentityOutsidePlatformTenant() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); - - assertThatThrownBy(() -> service(mapper) + assertThatThrownBy(() -> service .load(new SnapshotRequest(42L, 7L, 9L, "PLATFORM"))) .isInstanceOfSatisfying(BizException.class, exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); @@ -47,10 +49,9 @@ class AuthorizationSnapshotServiceTest { @Test void rejectsInactiveMembership() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(0); - assertThatThrownBy(() -> service(mapper) + assertThatThrownBy(() -> service .load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT"))) .isInstanceOfSatisfying(BizException.class, exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); @@ -59,13 +60,12 @@ class AuthorizationSnapshotServiceTest { @Test void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR")); when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")) .thenReturn(List.of("platform:order:list")); - var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, + var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); assertThat(result).containsExactly("platform:order:list"); @@ -74,7 +74,7 @@ class AuthorizationSnapshotServiceTest { @Test void rejectsIncompleteCurrentIdentityAsBusinessError() { - assertThatThrownBy(() -> service(mock(AuthorizationQueryMapper.class)) + assertThatThrownBy(() -> service .currentPermissions(new AuthenticatedUser(42L, 0L, 0L, null, "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM")) .isInstanceOfSatisfying(BizException.class, @@ -82,21 +82,15 @@ class AuthorizationSnapshotServiceTest { } @Test - void 空角色和权限查询结果不应触发五百错误() { - AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); + void shouldHandleCase01() { when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null); when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null); - var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, + var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); assertThat(result).isEmpty(); } - private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) { - AuthorizationSnapshotService service = new AuthorizationSnapshotService(); - ReflectionTestUtils.setField(service, "mapper", mapper); - return service; - } } diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java index 7c3140634..92f5639c6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java @@ -8,6 +8,10 @@ import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.CustomerListExportProviderRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +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.time.LocalDate; @@ -23,15 +27,14 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(CustomerListExportProviderService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes") class CustomerListExportProviderServiceTest { - private CustomerListExportProviderRepository repository; - private CustomerListExportProviderService service; + @MockBean private CustomerListExportProviderRepository repository; + @Resource private CustomerListExportProviderService service; @BeforeEach void setUp() { - repository = mock(CustomerListExportProviderRepository.class); - service = new CustomerListExportProviderService(repository, - "test-internal-service-token-at-least-32-bytes"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index a9e193697..179c23c79 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -12,6 +12,10 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; import java.util.Set; @@ -20,11 +24,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(MemberService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MemberServiceTest { - private final MemberRepository repository = mock(MemberRepository.class); - private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); - private final AuthAccountClient auth = mock(AuthAccountClient.class); - private final MemberService service = new MemberService(repository, roleMapper, auth, "internal-token"); + @MockBean private MemberRepository repository; + @MockBean private IamRoleMapper roleMapper; + @MockBean private AuthAccountClient auth; + @Resource private MemberService service; private IamMembership member; private IamUser user; @@ -37,7 +43,7 @@ class MemberServiceTest { when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L)); } - @Test void 成员详情返回联系方式和已有角色() { + @Test void shouldHandleCase01() { user.setPhone("13800138000"); user.setEmail("member@example.com"); MemberResponse result = service.detail(100L, 20L); assertEquals("测试成员", result.displayName()); @@ -46,12 +52,12 @@ class MemberServiceTest { verifyNoInteractions(auth); } - @Test void 跨租户查询被拒绝() { + @Test void shouldHandleCase02() { when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); assertThrows(BizException.class, () -> service.detail(200L, 20L)); } - @Test void 企业所有者不可停用() { + @Test void shouldHandleCase03() { member.setMemberType("OWNER"); BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 20L)); @@ -59,7 +65,7 @@ class MemberServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } - @Test void 停用成员同步认证账号并保存本地状态() { + @Test void shouldHandleCase04() { when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null)); MemberResponse result = service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L); @@ -68,14 +74,14 @@ class MemberServiceTest { verify(repository).saveUser(user); } - @Test void 认证服务失败时不得写入本地状态() { + @Test void shouldHandleCase05() { when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败")); assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L)); verify(repository, never()).save(member); } - @Test void 企业所有者不能通过成员编辑接口降级或变更角色() { + @Test void shouldHandleCase06() { member.setMemberType("OWNER"); UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null, "EMPLOYEE", null, List.of()); @@ -85,7 +91,7 @@ class MemberServiceTest { verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void 新增成员携带非空角色但无授权权限时返回403且不创建账号() { + @Test void shouldHandleCase07() { CreateMemberRequest request = new CreateMemberRequest("member01", "Password1", "成员", null, null, "EMPLOYEE", null, List.of(50L)); @@ -97,7 +103,7 @@ class MemberServiceTest { verify(repository, never()).insert(any()); } - @Test void 编辑资料且角色集合未变化时不要求角色授权权限() { + @Test void shouldHandleCase08() { IamRole role = activeMerchantRole(50L, 100L); when(roleMapper.selectById(50L)).thenReturn(role); UpdateMemberRequest request = new UpdateMemberRequest("新姓名", "13800138000", null, @@ -110,7 +116,7 @@ class MemberServiceTest { verify(repository).replaceRoles(100L, 20L, List.of(50L)); } - @Test void 编辑成员变更角色但无授权权限时返回403() { + @Test void shouldHandleCase09() { UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of(60L)); @@ -122,7 +128,7 @@ class MemberServiceTest { verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void 空集合清空已有角色同样要求角色授权权限() { + @Test void shouldHandleCase10() { UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of()); @@ -133,7 +139,7 @@ class MemberServiceTest { verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void 具有角色授权权限时允许清空已有角色() { + @Test void shouldHandleCase11() { UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of()); @@ -143,7 +149,7 @@ class MemberServiceTest { verify(repository).replaceRoles(100L, 20L, List.of()); } - @Test void 跨租户编辑先按目标租户查成员并返回404() { + @Test void shouldHandleCase12() { when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); UpdateMemberRequest request = new UpdateMemberRequest("测试成员", null, null, "EMPLOYEE", null, List.of(50L)); @@ -162,7 +168,7 @@ class MemberServiceTest { return role; } - @Test void 企业所有者密码不能被成员管理员重置() { + @Test void shouldHandleCase13() { member.setMemberType("OWNER"); assertThrows(BizException.class, () -> service.resetPassword(100L, 20L, 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 fc8ee4258..59644c2f1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -16,37 +16,32 @@ import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baiyue.iam.mapper.IamUserMapper; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(MerchantOnboardingService.class) class MerchantOnboardingServiceTest { + @MockBean private IamTenantMapper tenants; + @MockBean private IamUserMapper users; + @MockBean private IamMembershipMapper memberships; + @MockBean private IamRoleMapper roles; + @MockBean private IamMembershipRoleMapper membershipRoles; + @MockBean private IamTenantAddressMapper addresses; + @MockBean private IamTenantQualificationMapper qualifications; + @MockBean private IamTenantReviewMapper reviews; + @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { - IamTenantMapper tenants = mock(IamTenantMapper.class); - IamUserMapper users = mock(IamUserMapper.class); - IamMembershipMapper memberships = mock(IamMembershipMapper.class); - IamRoleMapper roles = mock(IamRoleMapper.class); - IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class); - IamTenantAddressMapper addresses = mock(IamTenantAddressMapper.class); - IamTenantQualificationMapper qualifications = mock(IamTenantQualificationMapper.class); - IamTenantReviewMapper reviews = mock(IamTenantReviewMapper.class); - MerchantOnboardingService service = new MerchantOnboardingService(); - ReflectionTestUtils.setField(service, "tenantMapper", tenants); - ReflectionTestUtils.setField(service, "userMapper", users); - ReflectionTestUtils.setField(service, "membershipMapper", memberships); - ReflectionTestUtils.setField(service, "roleMapper", roles); - ReflectionTestUtils.setField(service, "membershipRoleMapper", membershipRoles); - ReflectionTestUtils.setField(service, "addressMapper", addresses); - ReflectionTestUtils.setField(service, "qualificationMapper", qualifications); - ReflectionTestUtils.setField(service, "reviewMapper", reviews); IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); @@ -89,23 +84,6 @@ class MerchantOnboardingServiceTest { @Test void compensationRemovesOnlyAnUnstartedRegistration() { - IamTenantMapper tenants = mock(IamTenantMapper.class); - IamUserMapper users = mock(IamUserMapper.class); - IamMembershipMapper memberships = mock(IamMembershipMapper.class); - IamRoleMapper roles = mock(IamRoleMapper.class); - IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class); - IamTenantAddressMapper addresses = mock(IamTenantAddressMapper.class); - IamTenantQualificationMapper qualifications = mock(IamTenantQualificationMapper.class); - IamTenantReviewMapper reviews = mock(IamTenantReviewMapper.class); - MerchantOnboardingService service = new MerchantOnboardingService(); - ReflectionTestUtils.setField(service, "tenantMapper", tenants); - ReflectionTestUtils.setField(service, "userMapper", users); - ReflectionTestUtils.setField(service, "membershipMapper", memberships); - ReflectionTestUtils.setField(service, "roleMapper", roles); - ReflectionTestUtils.setField(service, "membershipRoleMapper", membershipRoles); - ReflectionTestUtils.setField(service, "addressMapper", addresses); - ReflectionTestUtils.setField(service, "qualificationMapper", qualifications); - ReflectionTestUtils.setField(service, "reviewMapper", reviews); IamUser user = new IamUser(); user.setId(20001L); @@ -135,23 +113,6 @@ class MerchantOnboardingServiceTest { @Test void compensationRefusesAfterQualificationUpload() { - IamTenantMapper tenants = mock(IamTenantMapper.class); - IamUserMapper users = mock(IamUserMapper.class); - IamMembershipMapper memberships = mock(IamMembershipMapper.class); - IamRoleMapper roles = mock(IamRoleMapper.class); - IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class); - IamTenantAddressMapper addresses = mock(IamTenantAddressMapper.class); - IamTenantQualificationMapper qualifications = mock(IamTenantQualificationMapper.class); - IamTenantReviewMapper reviews = mock(IamTenantReviewMapper.class); - MerchantOnboardingService service = new MerchantOnboardingService(); - ReflectionTestUtils.setField(service, "tenantMapper", tenants); - ReflectionTestUtils.setField(service, "userMapper", users); - ReflectionTestUtils.setField(service, "membershipMapper", memberships); - ReflectionTestUtils.setField(service, "roleMapper", roles); - ReflectionTestUtils.setField(service, "membershipRoleMapper", membershipRoles); - ReflectionTestUtils.setField(service, "addressMapper", addresses); - ReflectionTestUtils.setField(service, "qualificationMapper", qualifications); - ReflectionTestUtils.setField(service, "reviewMapper", reviews); IamUser user = new IamUser(); user.setId(20001L); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java index b5180667c..f99a921a9 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java @@ -6,7 +6,10 @@ import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.time.LocalDate; import java.util.List; @@ -15,22 +18,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; import com.baiyue.common.core.exception.BizException; +@SpringJUnitConfig(ProfileResourceService.class) class MerchantQualificationUploadTest { + @MockBean private TenantRepository tenantRepository; + @MockBean private ProfileResourceRepository profileRepository; + @Resource private ProfileResourceService service; @Test void uploadsOneOptionalDraftQualificationWithoutCreatingAReview() { - TenantRepository tenantRepository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - ProfileResourceService service = new ProfileResourceService(); - ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); - ReflectionTestUtils.setField(service, "repository", profileRepository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); @@ -50,11 +51,6 @@ class MerchantQualificationUploadTest { @Test void requiredQualificationsRejectTheLegacySingleFilePath() { - TenantRepository tenantRepository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - ProfileResourceService service = new ProfileResourceService(); - ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); - ReflectionTestUtils.setField(service, "repository", profileRepository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); @@ -69,11 +65,6 @@ class MerchantQualificationUploadTest { @Test void requiredPackagePersistsBothFilesAndCreditCodeTogether() { - TenantRepository tenantRepository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - ProfileResourceService service = new ProfileResourceService(); - ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); - ReflectionTestUtils.setField(service, "repository", profileRepository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); @@ -94,11 +85,6 @@ class MerchantQualificationUploadTest { @Test void requiredPackageCannotOverwriteAnEstablishedCreditCode() { - TenantRepository tenantRepository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - ProfileResourceService service = new ProfileResourceService(); - ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); - ReflectionTestUtils.setField(service, "repository", profileRepository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); diff --git a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java index 146c757f5..8f66b3ed4 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java @@ -7,14 +7,18 @@ import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @DisplayName("个人资料服务") +@SpringJUnitConfig(PersonalProfileService.class) class PersonalProfileServiceTest { - private final MemberRepository repository = mock(MemberRepository.class); - private final PersonalProfileService service = new PersonalProfileService(repository); + @MockBean private MemberRepository repository; + @Resource private PersonalProfileService service; @Test void canOnlyModifyOwnProfileWithinCurrentTenant() { IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE"); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index a2fb8253d..e231bf415 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -12,6 +12,10 @@ import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.List; @@ -19,12 +23,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(PlatformInternalUserService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class PlatformInternalUserServiceTest { - private final MemberRepository repository = mock(MemberRepository.class); - private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); - private final AuthAccountClient auth = mock(AuthAccountClient.class); - private final PlatformInternalUserService service = - new PlatformInternalUserService(repository, roleMapper, auth, "internal-token"); + @MockBean private MemberRepository repository; + @MockBean private IamRoleMapper roleMapper; + @MockBean private AuthAccountClient auth; + @Resource private PlatformInternalUserService service; private IamMembership membership; private IamUser user; @@ -45,7 +50,7 @@ class PlatformInternalUserServiceTest { } @Test - void 列表返回成员主键联系方式和当前角色() { + void shouldHandleCase01() { when(repository.findByTenant(0L)).thenReturn(List.of(membership)); InternalUserResponse result = service.list().get(0); @@ -57,7 +62,7 @@ class PlatformInternalUserServiceTest { } @Test - void 新建平台用户在Auth创建平台账号并写入IAM成员() { + void shouldHandleCase02() { IamRole role = new IamRole(); role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); when(roleMapper.selectById(40L)).thenReturn(role); @@ -81,7 +86,7 @@ class PlatformInternalUserServiceTest { } @Test - void 本地创建失败会补偿认证账号且不记录密码() { + void shouldHandleCase03() { when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE"))); when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null)); @@ -95,7 +100,7 @@ class PlatformInternalUserServiceTest { } @Test - void 不能停用当前登录的平台成员() { + void shouldHandleCase04() { assertThrows(BizException.class, () -> service.changeStatus(30L, new ChangeInternalUserStatusRequest("DISABLED"), 30L)); @@ -103,7 +108,7 @@ class PlatformInternalUserServiceTest { } @Test - void 状态本地写入失败时恢复Auth且成功后不再远程查询() { + void shouldHandleCase05() { when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user); @@ -118,7 +123,7 @@ class PlatformInternalUserServiceTest { } @Test - void 状态补偿应恢复Auth原状态而不是IAM旧状态() { + void shouldHandleCase06() { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED"))); when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); @@ -134,7 +139,7 @@ class PlatformInternalUserServiceTest { } @Test - void 重置密码只转交Auth不写入IAM() { + void shouldHandleCase07() { when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1")); @@ -145,7 +150,7 @@ class PlatformInternalUserServiceTest { } @Test - void 角色分配只接受平台已启用角色并返回最新回显() { + void shouldHandleCase08() { IamRole role = new IamRole(); role.setId(41L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); when(roleMapper.selectById(41L)).thenReturn(role); @@ -158,7 +163,7 @@ class PlatformInternalUserServiceTest { } @Test - void 禁止给平台用户分配商户角色() { + void shouldHandleCase09() { IamRole merchantRole = new IamRole(); merchantRole.setId(42L); merchantRole.setTenantId(10001L); merchantRole.setAppCode("MERCHANT"); merchantRole.setStatus("ACTIVE"); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java index e3dcd9b74..b6131881b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -7,26 +7,27 @@ import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.time.LocalDate; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(ProfileResourceService.class) class PlatformQualificationRegistrationTest { + @MockBean private ProfileResourceRepository repository; + @MockBean private TenantRepository tenantRepository; + @Resource private ProfileResourceService service; @Test void platformUploadRegistersOptionalMetadataWithoutCreatingReviewTicket() { - ProfileResourceRepository repository = mock(ProfileResourceRepository.class); - TenantRepository tenantRepository = mock(TenantRepository.class); - ProfileResourceService service = new ProfileResourceService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); @@ -48,11 +49,6 @@ class PlatformQualificationRegistrationTest { @Test void platformCanReplaceAnUploadedQualificationBeforeReview() { - ProfileResourceRepository repository = mock(ProfileResourceRepository.class); - TenantRepository tenantRepository = mock(TenantRepository.class); - ProfileResourceService service = new ProfileResourceService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); IamTenantQualification existing = new IamTenantQualification(); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 7b3294511..c79c9b22f 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -12,6 +12,10 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; import java.util.List; @@ -21,12 +25,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(PlatformUserManagementService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class PlatformUserManagementServiceTest { - private final MemberRepository members = mock(MemberRepository.class); - private final IamRoleMapper roles = mock(IamRoleMapper.class); - private final AuthAccountClient auth = mock(AuthAccountClient.class); - private final PlatformUserManagementService service = - new PlatformUserManagementService(members, roles, auth, "internal-token"); + @MockBean private MemberRepository members; + @MockBean private IamRoleMapper roles; + @MockBean private AuthAccountClient auth; + @Resource private PlatformUserManagementService service; private IamRole platformRole; private IamMembership membership; private IamUser user; diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 85bfbe967..ec8f8770f 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -15,13 +15,12 @@ import com.baiyue.iam.entity.IamPermission; import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.RbacRepository; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.util.List; import java.util.Set; @@ -30,21 +29,14 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@ExtendWith(MockitoExtension.class) +@SpringJUnitConfig(RbacService.class) class RbacServiceTest { - @Mock private RbacRepository repository; - @Mock private MemberRepository memberRepository; - private RbacService service; - - @BeforeEach - void setUp() { - service = new RbacService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "memberRepository", memberRepository); - } + @MockBean private RbacRepository repository; + @MockBean private MemberRepository memberRepository; + @Resource private RbacService service; @Test - void 角色列表返回当前权限用于安全回显() { + void shouldHandleCase01() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role)); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L)); @@ -56,7 +48,7 @@ class RbacServiceTest { } @Test - void 权限列表同码时优先当前租户并去除全局模板重复项() { + void shouldHandleCase02() { IamPermission global = permission(21L, 0L, "merchant:member:list"); IamPermission tenant = permission(22L, 100L, "merchant:member:list"); IamPermission fallback = permission(23L, 0L, "merchant:member:status"); @@ -71,7 +63,7 @@ class RbacServiceTest { } @Test - void 跨租户角色访问被拒绝() { + void shouldHandleCase03() { when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null); BizException exception = assertThrows(BizException.class, @@ -81,7 +73,7 @@ class RbacServiceTest { } @Test - void 系统角色禁止删除() { + void shouldHandleCase04() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -93,7 +85,7 @@ class RbacServiceTest { } @Test - void 已分配成员的自定义角色禁止删除() { + void shouldHandleCase05() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(true); @@ -106,7 +98,7 @@ class RbacServiceTest { } @Test - void 空权限集合可以明确清空授权() { + void shouldHandleCase06() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); @@ -116,7 +108,7 @@ class RbacServiceTest { } @Test - void 空角色集合可以明确清空成员授权() { + void shouldHandleCase07() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -128,7 +120,7 @@ class RbacServiceTest { } @Test - void 成员角色回显只返回当前应用域角色() { + void shouldHandleCase08() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -144,7 +136,7 @@ class RbacServiceTest { } @Test - void 同一租户同一应用角色编码重复时返回冲突() { + void shouldHandleCase09() { when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true); BizException exception = assertThrows(BizException.class, () -> service.createRole( @@ -155,7 +147,7 @@ class RbacServiceTest { } @Test - void 停用角色不得继续分配给成员() { + void shouldHandleCase10() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -172,7 +164,7 @@ class RbacServiceTest { } @Test - void 自定义角色可以编辑和停用并保持权限回显() { + void shouldHandleCase11() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L)); @@ -189,7 +181,7 @@ class RbacServiceTest { } @Test - void 未使用的自定义角色可以安全删除() { + void shouldHandleCase12() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(false); @@ -200,7 +192,7 @@ class RbacServiceTest { } @Test - void 企业所有者角色不能通过普通成员授权接口修改() { + void shouldHandleCase13() { IamMembership owner = new IamMembership(); owner.setId(31L); owner.setTenantId(100L); @@ -215,7 +207,7 @@ class RbacServiceTest { } @Test - void 系统角色权限不能通过普通授权接口修改() { + void shouldHandleCase14() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -227,7 +219,7 @@ class RbacServiceTest { } @Test - void 企业所有者系统角色不能分配给普通成员() { + void shouldHandleCase15() { IamMembership member = new IamMembership(); member.setId(31L); member.setTenantId(100L); @@ -245,7 +237,7 @@ class RbacServiceTest { } @Test - void 权限编码重复时返回稳定冲突且不写数据库() { + void shouldHandleCase16() { when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test")) .thenReturn(true); CreatePermissionRequest request = new CreatePermissionRequest( @@ -259,7 +251,7 @@ class RbacServiceTest { } @Test - void 系统预置权限不允许编辑启停或删除() { + void shouldHandleCase17() { IamPermission builtIn = permission(21L, 0L, "platform:permission:list"); builtIn.setAppCode("PLATFORM"); builtIn.setBuiltIn(true); @@ -274,7 +266,7 @@ class RbacServiceTest { } @Test - void 自定义权限能编辑和启停但保持编码不变() { + void shouldHandleCase18() { IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); @@ -290,7 +282,7 @@ class RbacServiceTest { } @Test - void 带子节点或角色绑定的权限资源拒绝删除() { + void shouldHandleCase19() { IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(true); @@ -308,7 +300,7 @@ class RbacServiceTest { } @Test - void 无引用的自定义权限可以删除() { + void shouldHandleCase20() { IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index fea122c96..b0bd881f1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -11,8 +11,10 @@ import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.util.TestPropertyValues; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.security.SecureRandom; import java.util.List; @@ -27,34 +29,23 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; +@SpringJUnitConfig(TenantAdministratorProvisioningService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class TenantAdministratorProvisioningServiceTest { - private final MemberRepository members = mock(MemberRepository.class); - private final IamRoleMapper roles = mock(IamRoleMapper.class); - private final AuthAccountClient auth = mock(AuthAccountClient.class); - private final TenantAdministratorProvisioningService service = - new TenantAdministratorProvisioningService(members, roles, auth, "internal-token", new SecureRandom()); + @MockBean private MemberRepository members; + @MockBean private IamRoleMapper roles; + @MockBean private AuthAccountClient auth; + @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @Test - void springContextUsesProductionConstructorWhenTestConstructorAlsoExists() { - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { - TestPropertyValues.of("baiyue.security.internal-service-token=internal-token-at-least-32-bytes-long") - .applyTo(context); - context.registerBean(MemberRepository.class, () -> mock(MemberRepository.class)); - context.registerBean(IamRoleMapper.class, () -> mock(IamRoleMapper.class)); - context.registerBean(AuthAccountClient.class, () -> mock(AuthAccountClient.class)); - context.register(TenantAdministratorProvisioningService.class); - - context.refresh(); - - assertNotNull(context.getBean(TenantAdministratorProvisioningService.class)); - } + void springContextCreatesBean() { + assertNotNull(service); } @BeforeEach diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java index 6483a5283..1325e23e5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantApprovalInitializationCoordinatorTest.java @@ -4,6 +4,7 @@ import com.baiyue.iam.entity.IamTenant; import org.junit.jupiter.api.Test; import org.mockito.InOrder; import org.springframework.core.annotation.Order; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; import java.util.ArrayList; import java.util.List; @@ -26,7 +27,7 @@ class TenantApprovalInitializationCoordinatorTest { when(first.initialize(context)).thenReturn(firstResult); when(second.initialize(context)).thenReturn(secondResult); when(failing.initialize(context)).thenThrow(new RuntimeException("role initialization failed")); - var coordinator = new TenantApprovalInitializationCoordinator(List.of(first, second, failing)); + var coordinator = coordinator(List.of(first, second, failing)); assertThrows(RuntimeException.class, () -> coordinator.initialize(context)); @@ -41,7 +42,7 @@ class TenantApprovalInitializationCoordinatorTest { TestResult result = new TestResult("credential-result"); var context = context(); when(initializer.initialize(context)).thenReturn(result); - var coordinator = new TenantApprovalInitializationCoordinator(List.of(initializer)); + var coordinator = coordinator(List.of(initializer)); var batch = coordinator.initialize(context); @@ -51,8 +52,7 @@ class TenantApprovalInitializationCoordinatorTest { @Test void springOrderControlsInitializerExecutionSequence() { List calls = new ArrayList<>(); - var coordinator = new TenantApprovalInitializationCoordinator( - List.of(new LateInitializer(calls), new EarlyInitializer(calls))); + var coordinator = coordinator(List.of(new LateInitializer(calls), new EarlyInitializer(calls))); coordinator.initialize(context()); @@ -65,6 +65,18 @@ class TenantApprovalInitializationCoordinatorTest { return initializer; } + private TenantApprovalInitializationCoordinator coordinator(List initializers) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + for (int index = 0; index < initializers.size(); index++) { + TenantApprovalInitializer initializer = initializers.get(index); + context.registerBean("initializer" + index, TenantApprovalInitializer.class, () -> initializer); + } + context.registerBean("initializers", List.class, () -> initializers); + context.registerBean(TenantApprovalInitializationCoordinator.class); + context.refresh(); + return context.getBean(TenantApprovalInitializationCoordinator.class); + } + private TenantApprovalInitializer.ApprovalContext context() { IamTenant tenant = new IamTenant(); tenant.setId(10001L); 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 2786137be..0194bb403 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -4,23 +4,26 @@ import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(TenantService.class) class TenantCustomerExportTest { + @MockBean private TenantRepository repository; + @MockBean private ProfileResourceRepository profileRepository; + @MockBean private TenantApprovalInitializationCoordinator approvalInitialization; + @Resource private TenantService service; @Test void returnsUnmaskedCustomerRowsAndAdvancingCursor() { - TenantRepository repository = mock(TenantRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", mock(ProfileResourceRepository.class)); IamTenant tenant = new IamTenant(); tenant.setId(101L); tenant.setTenantCode("TEN101"); 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 9a1e5afd4..6649d45cb 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -9,8 +9,16 @@ import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.ProfileResourceRepository; +import com.baiyue.iam.repository.MemberRepository; +import com.baiyue.iam.mapper.IamRoleMapper; +import com.baiyue.iam.client.AuthAccountClient; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import jakarta.annotation.Resource; import java.math.BigDecimal; import java.time.LocalDate; @@ -18,11 +26,11 @@ import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doReturn; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -30,13 +38,20 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; +@SpringJUnitConfig(classes = {TenantService.class, TenantApprovalInitializationCoordinator.class, + TenantAdministratorProvisioningService.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class TenantServiceTest { + @MockBean private TenantRepository repository; + @MockBean private ProfileResourceRepository profileRepository; + @MockBean private MemberRepository members; + @MockBean private IamRoleMapper roles; + @MockBean private AuthAccountClient authClient; + @SpyBean private TenantAdministratorProvisioningService provisioning; + @Resource private TenantService service; @Test void pageMerchantsMapsTenantEntitiesToApiResponses() { - TenantRepository repository = mock(TenantRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); IamTenant tenant = new IamTenant(); tenant.setId(10001L); @@ -72,9 +87,6 @@ class TenantServiceTest { @Test void platformCanMaintainAndDisableOnlyMerchantTenant() { - TenantRepository repository = mock(TenantRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); IamTenant tenant = merchant(10001L, "ACTIVE"); when(repository.findTenant(10001L)).thenReturn(tenant); @@ -102,9 +114,6 @@ class TenantServiceTest { @Test void platformCannotBypassReviewToActivatePendingTenant() { - TenantRepository repository = mock(TenantRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING")); BizException exception = assertThrows(BizException.class, @@ -115,9 +124,6 @@ class TenantServiceTest { @Test void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() { - TenantRepository repository = mock(TenantRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", @@ -135,9 +141,6 @@ class TenantServiceTest { @Test void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() { - TenantRepository repository = mock(TenantRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", @@ -150,11 +153,6 @@ class TenantServiceTest { @Test void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); when(profileRepository.findQualifications(10001L)).thenReturn(List.of( @@ -169,11 +167,6 @@ class TenantServiceTest { @Test void explicitSubmissionRejectsAnIncompleteQualificationSet() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED")); when(profileRepository.findQualifications(10001L)).thenReturn(List.of( qualification("BUSINESS_LICENSE"))); @@ -186,11 +179,6 @@ class TenantServiceTest { @Test void explicitSubmissionRejectsMissingUnifiedSocialCreditCodeBeforeCreatingReview() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); tenant.setUnifiedSocialCreditCode(null); when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); @@ -204,25 +192,16 @@ class TenantServiceTest { @Test void approvalProvisionsMissingAdministratorAndReturnsCredentialsOnce() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantAdministratorProvisioningService provisioning = mock(TenantAdministratorProvisioningService.class); when(provisioning.name()).thenReturn("tenant-administrator"); - TenantApprovalInitializationCoordinator initialization = - new TenantApprovalInitializationCoordinator(List.of(provisioning)); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); - ReflectionTestUtils.setField(service, "approvalInitialization", initialization); IamTenantReview review = review(71L, 10001L); IamTenant tenant = merchant(10001L, "PENDING"); when(repository.findReviewForUpdate(71L)).thenReturn(review); when(repository.findTenant(10001L)).thenReturn(tenant); when(profileRepository.findQualifications(10001L)).thenReturn(List.of( qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"))); - when(provisioning.initialize(any())).thenReturn( + doReturn( TenantAdministratorProvisioningService.ProvisioningResult.created( - 81L, "merchant_10001", "Password1234")); + 81L, "merchant_10001", "Password1234")).when(provisioning).initialize(any()); var result = service.review(71L, 9001L, true, "资料完整"); @@ -238,16 +217,7 @@ class TenantServiceTest { @Test void approvalKeepsClientRegisteredAccountAsAdministratorWithoutNewCredentials() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantAdministratorProvisioningService provisioning = mock(TenantAdministratorProvisioningService.class); when(provisioning.name()).thenReturn("tenant-administrator"); - TenantApprovalInitializationCoordinator initialization = - new TenantApprovalInitializationCoordinator(List.of(provisioning)); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); - ReflectionTestUtils.setField(service, "approvalInitialization", initialization); IamTenantReview review = review(71L, 10001L); IamTenant tenant = merchant(10001L, "PENDING"); when(repository.findReviewForUpdate(71L)).thenReturn(review); @@ -255,8 +225,8 @@ class TenantServiceTest { when(profileRepository.findQualifications(10001L)).thenReturn(List.of( qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"), qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE"))); - when(provisioning.initialize(any())).thenReturn( - TenantAdministratorProvisioningService.ProvisioningResult.existing()); + doReturn(TenantAdministratorProvisioningService.ProvisioningResult.existing()) + .when(provisioning).initialize(any()); var result = service.review(71L, 9001L, true, "资料完整"); @@ -269,16 +239,7 @@ class TenantServiceTest { @Test void failureAfterProvisioningCompensatesAdministratorAndKeepsApprovalFailed() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantAdministratorProvisioningService provisioning = mock(TenantAdministratorProvisioningService.class); when(provisioning.name()).thenReturn("tenant-administrator"); - TenantApprovalInitializationCoordinator initialization = - new TenantApprovalInitializationCoordinator(List.of(provisioning)); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); - ReflectionTestUtils.setField(service, "approvalInitialization", initialization); IamTenantReview review = review(71L, 10001L); IamTenant tenant = merchant(10001L, "PENDING"); var administrator = TenantAdministratorProvisioningService.ProvisioningResult.created( @@ -288,7 +249,7 @@ class TenantServiceTest { when(profileRepository.findQualifications(10001L)).thenReturn(List.of( qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"), qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE"))); - when(provisioning.initialize(any())).thenReturn(administrator); + doReturn(administrator).when(provisioning).initialize(any()); doThrow(new RuntimeException("review write failed")).when(repository).saveReview(review); assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "资料完整")); @@ -298,12 +259,7 @@ class TenantServiceTest { } @Test - void 企业激活后仅需两类必填资质审核通过即可下单() { - TenantRepository repository = mock(TenantRepository.class); - ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); - TenantService service = new TenantService(); - ReflectionTestUtils.setField(service, "repository", repository); - ReflectionTestUtils.setField(service, "profileRepository", profileRepository); + void shouldHandleCase01() { when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE")); when(profileRepository.findQualifications(10001L)).thenReturn( List.of(approvedQualification("BUSINESS_LICENSE")), diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java index 759db0b21..507b50b67 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java @@ -1,16 +1,14 @@ package com.baiyue.integration.provider; +import jakarta.annotation.Resource; + import com.baiyue.integration.config.IntegrationProperties; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; @Component public class AmazonLogisticsAdapter implements IntegrationProviderAdapter { - private final IntegrationProperties properties; - - public AmazonLogisticsAdapter(IntegrationProperties properties) { - this.properties = properties; - } + @Resource private IntegrationProperties properties; @Override public ProviderStatus status() { diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java index 651d5d0e6..5d4287c4e 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java @@ -1,16 +1,14 @@ package com.baiyue.integration.provider; +import jakarta.annotation.Resource; + import com.baiyue.integration.config.IntegrationProperties; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; @Component public class BaiduOcrAdapter implements IntegrationProviderAdapter { - private final IntegrationProperties properties; - - public BaiduOcrAdapter(IntegrationProperties properties) { - this.properties = properties; - } + @Resource private IntegrationProperties properties; @Override public ProviderStatus status() { diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java index e71774f31..ac36aa6fb 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java @@ -1,5 +1,8 @@ package com.baiyue.integration.provider.ocr; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.integration.config.IntegrationProperties; @@ -31,18 +34,19 @@ public class BaiduMeterClient { private static final int QPS_LIMIT_ERROR_CODE = 18; private static final long INITIAL_RETRY_DELAY_MILLIS = 1_000; - private final IntegrationProperties.BaiduOcr config; - private final ObjectMapper objectMapper; - private final HttpClient httpClient; + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.BaiduOcr config; + private HttpClient httpClient; private final Object requestRateLock = new Object(); private volatile CachedToken cachedToken; private long nextRequestAtMillis; - public BaiduMeterClient(IntegrationProperties properties, ObjectMapper objectMapper) { - this.config = properties.baiduOcr(); - this.objectMapper = objectMapper; - this.httpClient = HttpClient.newBuilder() + @PostConstruct + void initializeClient() { + config = properties.baiduOcr(); + httpClient = HttpClient.newBuilder() .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout()) .build(); diff --git a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java index e5d5932fc..567962c8d 100644 --- a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java +++ b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java @@ -1,5 +1,7 @@ package com.baiyue.integration.security; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import org.springframework.beans.factory.annotation.Value; @@ -10,9 +12,11 @@ import java.security.MessageDigest; @Component public class InternalServiceTokenGuard { - private final byte[] expectedToken; + @Value("${baiyue.security.internal-service-token}") private String token; + private byte[] expectedToken; - public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) { + @PostConstruct + void initializeToken() { if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) { throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); } diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java index 63b23feaf..4c0aba2f3 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java @@ -1,5 +1,7 @@ package com.baiyue.integration.service; +import jakarta.annotation.Resource; + import com.baiyue.integration.dto.ProviderStatus; import com.baiyue.integration.provider.IntegrationProviderAdapter; import org.springframework.stereotype.Service; @@ -8,11 +10,7 @@ import java.util.List; @Service public class ProviderStatusService { - private final List adapters; - - public ProviderStatusService(List adapters) { - this.adapters = adapters; - } + @Resource private List adapters; public List providers() { return adapters.stream().map(IntegrationProviderAdapter::status).toList(); diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java index f0f243598..7ddfb49cb 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrBatchService.java @@ -1,5 +1,7 @@ package com.baiyue.integration.service.ocr; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.integration.config.IntegrationProperties; @@ -33,28 +35,14 @@ import java.util.stream.Stream; public class WeightOcrBatchService { private static final Set SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp"); - private final IntegrationProperties properties; - private final WeightOcrStoragePolicy storagePolicy; - private final ImageCropper imageCropper; - private final BaiduMeterClient baiduMeterClient; - private final WeightCandidateSelector candidateSelector; - private final ObjectMapper objectMapper; + @Resource private IntegrationProperties properties; + @Resource private WeightOcrStoragePolicy storagePolicy; + @Resource private ImageCropper imageCropper; + @Resource private BaiduMeterClient baiduMeterClient; + @Resource private WeightCandidateSelector candidateSelector; + @Resource private ObjectMapper objectMapper; private final Semaphore batchSlot = new Semaphore(1); - public WeightOcrBatchService(IntegrationProperties properties, - WeightOcrStoragePolicy storagePolicy, - ImageCropper imageCropper, - BaiduMeterClient baiduMeterClient, - WeightCandidateSelector candidateSelector, - ObjectMapper objectMapper) { - this.properties = properties; - this.storagePolicy = storagePolicy; - this.imageCropper = imageCropper; - this.baiduMeterClient = baiduMeterClient; - this.candidateSelector = candidateSelector; - this.objectMapper = objectMapper; - } - public WeightOcrBatchResult recognize(WeightOcrBatchRequest request) throws IOException { baiduMeterClient.validateReady(); if (!batchSlot.tryAcquire()) { diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java index 6e531e3d4..991c111fe 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrSingleImageService.java @@ -1,5 +1,7 @@ package com.baiyue.integration.service.ocr; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.integration.config.IntegrationProperties; @@ -19,13 +21,8 @@ import java.util.UUID; @Service public class WeightOcrSingleImageService { private static final Set CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/bmp"); - private final IntegrationProperties properties; - private final WeightOcrBatchService batches; - - public WeightOcrSingleImageService(IntegrationProperties properties, WeightOcrBatchService batches) { - this.properties = properties; - this.batches = batches; - } + @Resource private IntegrationProperties properties; + @Resource private WeightOcrBatchService batches; public WeightOcrImageResult recognize(MultipartFile file) throws IOException { IntegrationProperties.BaiduOcr config = properties.baiduOcr(); diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java index a98647747..f003da7f1 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicy.java @@ -1,5 +1,7 @@ package com.baiyue.integration.service.ocr; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.integration.config.IntegrationProperties; @@ -14,11 +16,7 @@ import java.nio.file.Path; */ @Component public class WeightOcrStoragePolicy { - private final IntegrationProperties properties; - - public WeightOcrStoragePolicy(IntegrationProperties properties) { - this.properties = properties; - } + @Resource private IntegrationProperties properties; public BatchPaths resolve(String inputValue, String outputValue, String batchId) throws IOException { IntegrationProperties.BaiduOcr config = requireConfig(); diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 6fa06c679..931d8b69f 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -76,6 +76,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java index 56dfa1b34..efe4fdc91 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -6,10 +6,15 @@ import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status; import com.baiyue.integration.provider.ocr.BaiduMeterClient; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.util.unit.DataSize; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import javax.imageio.ImageIO; import java.awt.Color; @@ -26,9 +31,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @DisplayName("OCR 重量批量识别功能") +@SpringJUnitConfig(classes = {WeightOcrBatchService.class, WeightOcrStoragePolicy.class, + ImageCropper.class, WeightCandidateSelector.class, WeightOcrBatchServiceTest.TestConfig.class}) class WeightOcrBatchServiceTest { @TempDir Path tempDirectory; + @MockBean private IntegrationProperties properties; + @MockBean private BaiduMeterClient client; + @Resource private WeightOcrBatchService service; @Test void completesBatchAndWritesArtifactsWithoutCallingRealProvider() throws Exception { @@ -43,15 +53,10 @@ class WeightOcrBatchServiceTest { } ImageIO.write(image, "png", batchInput.resolve("weight.png").toFile()); - IntegrationProperties properties = properties(inputRoot, outputRoot); - BaiduMeterClient client = mock(BaiduMeterClient.class); + when(properties.baiduOcr()).thenReturn(properties(inputRoot, outputRoot).baiduOcr()); when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse( "{\"words_result\":[]}", List.of(new OcrCandidate("12.35", 0.98, 10, 10, 80, 30)))); - ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - WeightOcrBatchService service = new WeightOcrBatchService( - properties, new WeightOcrStoragePolicy(properties), new ImageCropper(), client, - new WeightCandidateSelector(), objectMapper); var result = service.recognize(new WeightOcrBatchRequest("batch-a", null, null, null, false)); @@ -72,4 +77,12 @@ class WeightOcrBatchServiceTest { 50, Duration.ZERO, 0); return new IntegrationProperties(baidu, null); } + + @Configuration + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + } } diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java index c36445292..206f5ef4e 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java @@ -7,6 +7,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.util.unit.DataSize; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.net.URI; import java.nio.file.Files; @@ -15,17 +18,21 @@ import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; @DisplayName("OCR 文件存储策略功能") +@SpringJUnitConfig(WeightOcrStoragePolicy.class) class WeightOcrStoragePolicyTest { @TempDir Path tempDirectory; + @MockBean private IntegrationProperties properties; + @Resource private WeightOcrStoragePolicy policy; @Test void resolvesDirectoriesInsideConfiguredRoots() throws Exception { Path input = Files.createDirectories(tempDirectory.resolve("input/batch-a")); Path outputRoot = tempDirectory.resolve("output"); - WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(input.getParent(), outputRoot)); + when(properties.baiduOcr()).thenReturn(config(input.getParent(), outputRoot)); WeightOcrStoragePolicy.BatchPaths paths = policy.resolve("batch-a", null, "batch-id"); @@ -38,7 +45,7 @@ class WeightOcrStoragePolicyTest { Path inputRoot = Files.createDirectories(tempDirectory.resolve("input")); Path outputRoot = tempDirectory.resolve("output"); Path outside = Files.createDirectories(tempDirectory.resolve("outside")); - WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(inputRoot, outputRoot)); + when(properties.baiduOcr()).thenReturn(config(inputRoot, outputRoot)); assertThatThrownBy(() -> policy.resolve(outside.toString(), null, "batch-id")) .isInstanceOfSatisfying(BizException.class, @@ -48,12 +55,12 @@ class WeightOcrStoragePolicyTest { exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); } - private IntegrationProperties properties(Path inputRoot, Path outputRoot) { + private IntegrationProperties.BaiduOcr config(Path inputRoot, Path outputRoot) { IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr( true, "key", "secret", URI.create("https://example.com/token"), URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1), inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L, 50, Duration.ZERO, 0); - return new IntegrationProperties(baidu, null); + return baidu; } } diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 9cc81ab29..051fff45e 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -1,5 +1,8 @@ package com.baiyue.order.controller; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -19,13 +22,13 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/order/v1/cargoes") public class InternalOrderCargoController { - private final OrderRepository orders; - private final byte[] expectedToken; + @Resource private OrderRepository orders; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; - public InternalOrderCargoController(OrderRepository orders, - @Value("${baiyue.security.internal-service-token}") String token) { - this.orders = orders; - this.expectedToken = token.getBytes(StandardCharsets.UTF_8); + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } @GetMapping("/{tenantId}/{orderId}/{cargoId}") diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index eaf95844b..3b5b1af26 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -1,5 +1,7 @@ package com.baiyue.order.repository; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.PageResult; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.MerchantInboundOrderQuery; @@ -22,28 +24,14 @@ import java.time.LocalDateTime; */ @Repository public class OrderRepository { - private final OrderMapper orderMapper; - private final OrderCargoMapper cargoMapper; - private final OrderPartyMapper partyMapper; - private final OrderInternalNoteMapper noteMapper; - private final OrderStatusHistoryMapper historyMapper; - private final OutboxEventMapper outboxMapper; - private final OrderChildPurgeMapper childPurgeMapper; - private final OrderIdempotencyRecordMapper idempotencyMapper; - - public OrderRepository(OrderMapper orderMapper, OrderCargoMapper cargoMapper, OrderPartyMapper partyMapper, - OrderInternalNoteMapper noteMapper, OrderStatusHistoryMapper historyMapper, - OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper, - OrderIdempotencyRecordMapper idempotencyMapper) { - this.orderMapper = orderMapper; - this.cargoMapper = cargoMapper; - this.partyMapper = partyMapper; - this.noteMapper = noteMapper; - this.historyMapper = historyMapper; - this.outboxMapper = outboxMapper; - this.childPurgeMapper = childPurgeMapper; - this.idempotencyMapper = idempotencyMapper; - } + @Resource private OrderMapper orderMapper; + @Resource private OrderCargoMapper cargoMapper; + @Resource private OrderPartyMapper partyMapper; + @Resource private OrderInternalNoteMapper noteMapper; + @Resource private OrderStatusHistoryMapper historyMapper; + @Resource private OutboxEventMapper outboxMapper; + @Resource private OrderChildPurgeMapper childPurgeMapper; + @Resource private OrderIdempotencyRecordMapper idempotencyMapper; public void insertOrder(OrderEntity order) { orderMapper.insert(order); } public int saveOrder(OrderEntity order) { return orderMapper.updateById(order); } diff --git a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java index 11cc7c711..0155274d6 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java @@ -1,5 +1,7 @@ package com.baiyue.order.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -24,18 +26,9 @@ import java.util.stream.Collectors; @Service public class MerchantInboundOrderListService { - private final OrderRepository repository; - private final FirstlegInboundClient firstleg; - private final String internalToken; - - public MerchantInboundOrderListService( - OrderRepository repository, - FirstlegInboundClient firstleg, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.repository = repository; - this.firstleg = firstleg; - this.internalToken = internalToken; - } + @Resource private OrderRepository repository; + @Resource private FirstlegInboundClient firstleg; + @Value("${baiyue.security.internal-service-token}") private String internalToken; public PageResult page(long tenantId, MerchantInboundOrderQuery query) { PageResult source = repository.merchantInboundPage(tenantId, query); diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 419beca7a..a3c8dc349 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -1,5 +1,7 @@ package com.baiyue.order.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.order.client.FirstlegInboundClient; import com.baiyue.order.entity.OutboxEventEntity; @@ -14,19 +16,10 @@ import java.time.LocalDateTime; /** 可靠投递订单审核事件;接收端以订单唯一键保证重复投递安全。 */ @Component public class OrderInboundOutboxPublisher { - private final OrderRepository repository; - private final FirstlegInboundClient client; - private final ObjectMapper json; - private final String internalToken; - - public OrderInboundOutboxPublisher(OrderRepository repository, FirstlegInboundClient client, - ObjectMapper json, - @Value("${baiyue.security.internal-service-token}") String internalToken) { - this.repository = repository; - this.client = client; - this.json = json; - this.internalToken = internalToken; - } + @Resource private OrderRepository repository; + @Resource private FirstlegInboundClient client; + @Resource private ObjectMapper json; + @Value("${baiyue.security.internal-service-token}") private String internalToken; @Scheduled(fixedDelayString = "${baiyue.order.inbound-outbox.fixed-delay-ms:3000}") public void publishApprovedOrders() { diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index b81015a2b..b71f55a2f 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -1,5 +1,7 @@ package com.baiyue.order.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; @@ -18,15 +20,11 @@ import org.slf4j.LoggerFactory; @Service public class OrderMasterDataGuard { private static final Logger log = LoggerFactory.getLogger(OrderMasterDataGuard.class); - private final WarehouseClient warehouses; - private final PartnerClient partners; - private final String token; + @Resource private WarehouseClient warehouses; + @Resource private PartnerClient partners; + @Value("${baiyue.security.internal-service-token}") private String token; @Value("${baiyue.warehouse.public-tenant-id:10001}") private long publicTenantId = 10001L; - public OrderMasterDataGuard(WarehouseClient warehouses, PartnerClient partners, - @Value("${baiyue.security.internal-service-token}") String token) { - this.warehouses=warehouses; this.partners=partners; this.token=token; - } public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) { try { PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(tenantId,request.customerId()); diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index eee2d3faa..17e31bf60 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -41,6 +41,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 95321bf92..0c419a953 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -4,6 +4,10 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -11,13 +15,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(InternalOrderCargoController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalOrderCargoControllerTest { - private final OrderRepository orders = mock(OrderRepository.class); - private final InternalOrderCargoController controller = - new InternalOrderCargoController(orders, "internal-token"); + @MockBean private OrderRepository orders; + @Resource private InternalOrderCargoController controller; @Test - void 只返回指定租户订单下的已有货物明细() { + void shouldHandleCase01() { OrderCargoEntity cargo = new OrderCargoEntity(); cargo.setId(31L); cargo.setOrderId(21L); @@ -35,7 +40,7 @@ class InternalOrderCargoControllerTest { } @Test - void 内部令牌错误时拒绝查询() { + void shouldHandleCase02() { assertThatThrownBy(() -> controller.detail("bad-token", 11L, 21L, 31L)) .isInstanceOf(BizException.class) .hasMessageContaining("凭证无效"); diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index 1ffc4573e..32a96a7db 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -5,6 +5,9 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.service.OrderApplicationService; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; import java.util.Set; @@ -14,14 +17,15 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +@SpringJUnitConfig(PlatformFirstLegOrderController.class) class PlatformOrderPageControllerTest { + @MockBean private OrderApplicationService service; + @Resource private PlatformFirstLegOrderController controller; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); @Test void omittedTenantQueriesAcrossAllTenantsForPlatformUser() { - OrderApplicationService service = mock(OrderApplicationService.class); - PlatformFirstLegOrderController controller = controller(service); controller.page(user("PLATFORM"), null, query); @@ -30,8 +34,6 @@ class PlatformOrderPageControllerTest { @Test void suppliedTenantFiltersOneTenantForPlatformUser() { - OrderApplicationService service = mock(OrderApplicationService.class); - PlatformFirstLegOrderController controller = controller(service); controller.page(user("PLATFORM"), 2084455952401629185L, query); @@ -40,8 +42,6 @@ class PlatformOrderPageControllerTest { @Test void invalidTenantFilterIsRejectedBeforeQuery() { - OrderApplicationService service = mock(OrderApplicationService.class); - PlatformFirstLegOrderController controller = controller(service); assertThatThrownBy(() -> controller.page(user("PLATFORM"), 0L, query)) .isInstanceOf(BizException.class) @@ -51,8 +51,6 @@ class PlatformOrderPageControllerTest { @Test void merchantCannotUseCrossTenantPlatformList() { - OrderApplicationService service = mock(OrderApplicationService.class); - PlatformFirstLegOrderController controller = controller(service); assertThatThrownBy(() -> controller.page(user("MERCHANT"), null, query)) .isInstanceOf(BizException.class) @@ -60,12 +58,6 @@ class PlatformOrderPageControllerTest { verifyNoInteractions(service); } - private PlatformFirstLegOrderController controller(OrderApplicationService service) { - PlatformFirstLegOrderController controller = new PlatformFirstLegOrderController(); - ReflectionTestUtils.setField(controller, "service", service); - return controller; - } - private AuthenticatedUser user(String appCode) { return new AuthenticatedUser(1L, null, null, null, "operator", appCode, "WEB", Set.of()); } diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java index eaa5a7f3e..c58744782 100644 --- a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java @@ -18,6 +18,9 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -25,20 +28,24 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(OrderRepository.class) class MerchantInboundOrderQueryRepositoryTest { + @MockBean private OrderMapper mapper; + @MockBean private OrderCargoMapper cargoMapper; + @MockBean private OrderPartyMapper partyMapper; + @MockBean private OrderInternalNoteMapper noteMapper; + @MockBean private OrderStatusHistoryMapper historyMapper; + @MockBean private OutboxEventMapper outboxMapper; + @MockBean private OrderChildPurgeMapper purgeMapper; + @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @Resource private OrderRepository repository; @Test @SuppressWarnings({"rawtypes", "unchecked"}) - void 查询始终带租户并覆盖页面筛选条件() { + void shouldHandleCase01() { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), "merchant-inbound-query-test"), OrderEntity.class); - OrderMapper mapper = mock(OrderMapper.class); when(mapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>()); - OrderRepository repository = new OrderRepository( - mapper, mock(OrderCargoMapper.class), mock(OrderPartyMapper.class), - mock(OrderInternalNoteMapper.class), mock(OrderStatusHistoryMapper.class), - mock(OutboxEventMapper.class), mock(OrderChildPurgeMapper.class), - mock(OrderIdempotencyRecordMapper.class)); MerchantInboundOrderQuery query = new MerchantInboundOrderQuery( "客户", "FL-", 11L, "美森", "13800000000", 30001L, null, OrderStatus.APPROVED, "FBA", 22L, null, null, 1, 20); diff --git a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java index 08316e1d8..3fac52237 100644 --- a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java @@ -14,6 +14,9 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -21,17 +24,24 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(OrderRepository.class) class PlatformOrderPageRepositoryTest { + @MockBean private OrderMapper mapper; + @MockBean private OrderCargoMapper cargoMapper; + @MockBean private OrderPartyMapper partyMapper; + @MockBean private OrderInternalNoteMapper noteMapper; + @MockBean private OrderStatusHistoryMapper historyMapper; + @MockBean private OutboxEventMapper outboxMapper; + @MockBean private OrderChildPurgeMapper purgeMapper; + @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @Resource private OrderRepository repository; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); @Test @SuppressWarnings({"rawtypes", "unchecked"}) void tenantPredicateIsAddedOnlyWhenFilterIsSupplied() { - OrderMapper mapper = mock(OrderMapper.class); when(mapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>()); - OrderRepository repository = repository(mapper); - repository.page(null, query); repository.page(20002L, query); @@ -41,16 +51,4 @@ class PlatformOrderPageRepositoryTest { assertThat(wrapperCaptor.getAllValues().get(1).getExpression().getNormal()).isNotEmpty(); } - private OrderRepository repository(OrderMapper mapper) { - return new OrderRepository( - mapper, - mock(OrderCargoMapper.class), - mock(OrderPartyMapper.class), - mock(OrderInternalNoteMapper.class), - mock(OrderStatusHistoryMapper.class), - mock(OutboxEventMapper.class), - mock(OrderChildPurgeMapper.class), - mock(OrderIdempotencyRecordMapper.class) - ); - } } diff --git a/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java index 941e2506f..c4002afcb 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java @@ -11,6 +11,10 @@ import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.entity.OrderStatusHistoryEntity; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +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; @@ -20,14 +24,15 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(MerchantInboundOrderListService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MerchantInboundOrderListServiceTest { - private final OrderRepository repository = mock(OrderRepository.class); - private final FirstlegInboundClient firstleg = mock(FirstlegInboundClient.class); - private final MerchantInboundOrderListService service = - new MerchantInboundOrderListService(repository, firstleg, "internal-token"); + @MockBean private OrderRepository repository; + @MockBean private FirstlegInboundClient firstleg; + @Resource private MerchantInboundOrderListService service; @Test - void 聚合客户端列表所需的订单货物收货方和入仓编码() { + void shouldHandleCase01() { MerchantInboundOrderQuery query = new MerchantInboundOrderQuery( null, null, null, null, null, null, null, null, null, null, null, null, 1, 20); OrderEntity order = new OrderEntity(); 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 ba58f07f3..d90ea515a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -12,6 +12,12 @@ import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; @@ -22,7 +28,18 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @DisplayName("订单状态流转") +@SpringJUnitConfig(classes = {OrderApplicationService.class, OrderApplicationServiceStateTest.TestConfig.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class OrderApplicationServiceStateTest { + @MockBean private OrderRepository contextRepository; + @MockBean private OrderEligibilityGuard contextEligibility; + @MockBean private OrderMasterDataGuard contextMasterData; + @Resource private OrderApplicationService springService; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } @Test void unapprovedTenantCanCreateDraft() { OrderRepository repository = mock(OrderRepository.class); @@ -89,7 +106,7 @@ class OrderApplicationServiceStateTest { OrderEntity order=new OrderEntity(); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.SUBMITTED); when(repository.findOwned(10001,1)).thenReturn(order); OrderEligibilityGuard eligibility=mock(OrderEligibilityGuard.class); - OrderApplicationService service=new OrderApplicationService(); + OrderApplicationService service=springService; ReflectionTestUtils.setField(service,"repository",repository); ReflectionTestUtils.setField(service,"eligibilityGuard",eligibility); ReflectionTestUtils.setField(service,"masterDataGuard",mock(OrderMasterDataGuard.class)); @@ -163,7 +180,7 @@ class OrderApplicationServiceStateTest { private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility, OrderMasterDataGuard masterData) { - OrderApplicationService service = new OrderApplicationService(); + OrderApplicationService service = springService; ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility); ReflectionTestUtils.setField(service, "masterDataGuard", masterData); 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 35e841a9c..7315583b0 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -11,6 +11,12 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicReference; @@ -27,7 +33,18 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("订单幂等处理") +@SpringJUnitConfig(classes = {OrderApplicationService.class, OrderIdempotencyServiceTest.TestConfig.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class OrderIdempotencyServiceTest { + @MockBean private OrderRepository contextRepository; + @MockBean private OrderEligibilityGuard contextEligibility; + @MockBean private OrderMasterDataGuard contextMasterData; + @Resource private OrderApplicationService springService; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } @Test void sameKeyAndRequestReturnsFirstCreatedOrderId() { OrderRepository repository = mock(OrderRepository.class); @@ -155,7 +172,7 @@ class OrderIdempotencyServiceTest { } private OrderApplicationService service(OrderRepository repository) { - OrderApplicationService service = new OrderApplicationService(); + OrderApplicationService service = springService; ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "json", new ObjectMapper()); ReflectionTestUtils.setField(service, "eligibilityGuard", mock(OrderEligibilityGuard.class)); diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index de91a6a5f..929c07d50 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -11,6 +11,10 @@ import feign.Request; import feign.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +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.nio.charset.StandardCharsets; import java.util.Collections; @@ -20,36 +24,38 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @DisplayName("订单主数据校验") +@SpringJUnitConfig(OrderMasterDataGuard.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=token") class OrderMasterDataGuardTest { + @MockBean private WarehouseClient warehouses; + @MockBean private PartnerClient partners; + @Resource private OrderMasterDataGuard guard; private static final long PUBLIC_TENANT_ID = 10001L; @Test void validMasterDataReturnsTrustedServerSnapshot() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null); var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0)); var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability)); when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner)); when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh)); - var result=new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)); + var result=guard.validate(10001,request(7L,8L)); assertThat(result.partner().partnerName()).isEqualTo("服务端客户名"); assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓"); } @Test void inactiveOrCrossTenantMasterDataIsRejected() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null)); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10002,request(7L,8L))) .isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用"); } @Test void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenReturn( ApiResponse.failure(ResultCode.NOT_FOUND,"往来单位不存在",null,null)); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .hasMessage("往来单位不存在") .extracting(exception -> ((BizException) exception).getResultCode()) @@ -58,24 +64,22 @@ class OrderMasterDataGuardTest { } @Test void masterDataResponseWithNullElementReturnsControlledBusinessError() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER")); var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"), Collections.singletonList(null), Collections.singletonList(null)); when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner)); when(warehouses.active("token",10001,8)).thenReturn(ApiResponse.success(wh)); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.VALIDATION_FAILED); } @Test void masterDataResponseMissingSnapshotNameIsServiceUnavailable() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE","SHIPPER",List.of("SHIPPER")))); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); @@ -83,30 +87,27 @@ class OrderMasterDataGuardTest { } @Test void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败")); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); } @Test void downstreamForbiddenMapsToServiceUnavailable() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenThrow(feign(403)); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); } @Test void downstreamHttpNotFoundReturnsUnboundCustomerMessage() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenThrow(feign(404)); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .hasMessage("\u672a\u7ed1\u5b9a\u53ef\u7528\u5f80\u6765\u5ba2\u6237") .extracting(exception -> ((BizException) exception).getResultCode()) @@ -114,42 +115,38 @@ class OrderMasterDataGuardTest { } @Test void wrappedDownstreamHttpConflictMapsToServiceUnavailable() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("wrapped", feign(409))); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); } @Test void downstreamServerErrorMapsToServiceUnavailable() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenThrow(feign(500)); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); } @Test void inactiveMasterDataInSuccessfulResponseMapsToConflict() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)) .thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE","SHIPPER",List.of("SHIPPER")))); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.CONFLICT); } @Test void partnerWithoutShipperTypeIsRejected() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenReturn(ApiResponse.success( new PartnerClient.Partner(7L,10001L,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE")))); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + assertThatThrownBy(() -> guard.validate(10001,request(7L,8L))) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.VALIDATION_FAILED); @@ -157,23 +154,21 @@ class OrderMasterDataGuardTest { } @Test void customerTenantCanUseDomesticWarehouseFromPublicTenant() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",20002,7)).thenReturn(ApiResponse.success( new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( warehouse(8L,"DOMESTIC",true))); - new OrderMasterDataGuard(warehouses,partners,"token").validate(20002,request(7L,8L)); + guard.validate(20002,request(7L,8L)); verify(warehouses).active("token",PUBLIC_TENANT_ID,8); } @Test void orderWithoutPartnerUsesPublicWarehouseWithoutPartnerLookup() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( warehouse(8L,"DOMESTIC",true))); - var result=new OrderMasterDataGuard(warehouses,partners,"token") + var result=guard .validate(20002,request(null,8L)); assertThat(result.partner()).isNull(); @@ -182,7 +177,6 @@ class OrderMasterDataGuardTest { } @Test void fbaDestinationMustReferenceMatchingPublicWarehouse() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",20002,7)).thenReturn(ApiResponse.success( new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( @@ -190,20 +184,19 @@ class OrderMasterDataGuardTest { when(warehouses.active("token",PUBLIC_TENANT_ID,9)).thenReturn(ApiResponse.success( warehouse(9L,"OVERSEAS",false))); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token") + assertThatThrownBy(() -> guard .validate(20002,request(7L,8L,"FBA",9L))) .isInstanceOf(BizException.class) .hasMessageContaining("目的仓库类型必须为 FBA"); } @Test void privateDestinationCannotReferencePublicWarehouse() { - WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",20002,7)).thenReturn(ApiResponse.success( new PartnerClient.Partner(7L,20002L,"客户","ACTIVE","SHIPPER",List.of("SHIPPER")))); when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success( warehouse(8L,"DOMESTIC",true))); - assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token") + assertThatThrownBy(() -> guard .validate(20002,request(7L,8L,"PRIVATE",9L))) .isInstanceOf(BizException.class) .hasMessageContaining("私人地址不能引用公共仓库"); 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 28a61ee11..d11f28798 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -3,21 +3,31 @@ package com.baiyue.order.service; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +@SpringJUnitConfig(classes = {OrderApplicationService.class, PlatformOrderPageServiceTest.TestConfig.class}) class PlatformOrderPageServiceTest { + @MockBean private OrderRepository repository; + @MockBean private OrderEligibilityGuard eligibilityGuard; + @MockBean private OrderMasterDataGuard masterDataGuard; + @Resource private OrderApplicationService service; + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); @Test void passesOptionalTenantScopeToRepository() { - OrderRepository repository = mock(OrderRepository.class); - OrderApplicationService service = new OrderApplicationService(); - ReflectionTestUtils.setField(service, "repository", repository); - service.page(null, query); service.page(20002L, query); 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 e0d5849d8..6de6404b8 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -2,7 +2,12 @@ package com.baiyue.order.service; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import java.util.List; import java.util.Map; @@ -11,14 +16,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@SpringJUnitConfig(classes = {OrderApplicationService.class, TenantOrderCountServiceTest.TestConfig.class}) class TenantOrderCountServiceTest { + @MockBean private OrderRepository repository; + @MockBean private OrderEligibilityGuard eligibilityGuard; + @MockBean private OrderMasterDataGuard masterDataGuard; + @Resource private OrderApplicationService service; + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + } @Test void returnsZeroForTenantsWithoutOrdersAndDeduplicatesRequestedIds() { - OrderRepository repository = mock(OrderRepository.class); when(repository.countByTenantIds(List.of(8L, 9L, 8L))).thenReturn(Map.of(8L, 12L)); - OrderApplicationService service = new OrderApplicationService(); - ReflectionTestUtils.setField(service, "repository", repository); - assertThat(service.countByTenantIds(List.of(8L, 9L, 8L))) .extracting("tenantId", "orderCount") .containsExactly( diff --git a/print/src/main/java/com/baiyue/print/config/PrintFontHealthIndicator.java b/print/src/main/java/com/baiyue/print/config/PrintFontHealthIndicator.java index 28b6bf52a..e07e3c838 100644 --- a/print/src/main/java/com/baiyue/print/config/PrintFontHealthIndicator.java +++ b/print/src/main/java/com/baiyue/print/config/PrintFontHealthIndicator.java @@ -1,5 +1,8 @@ package com.baiyue.print.config; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + import java.io.InputStream; import org.apache.fontbox.ttf.TTFParser; import org.apache.fontbox.ttf.TrueTypeFont; @@ -11,11 +14,11 @@ import org.springframework.stereotype.Component; /** Exposes font packaging/parsing failures through readiness health. */ @Component("printFont") public class PrintFontHealthIndicator implements HealthIndicator { - private final Health validationResult; + @Resource private PrintPdfProperties properties; + private Health validationResult; - public PrintFontHealthIndicator(PrintPdfProperties properties) { - this.validationResult = validate(properties.getFontResource()); - } + @PostConstruct + void initializeHealth() { validationResult = validate(properties.getFontResource()); } @Override public Health health() { diff --git a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java index 2c77c3b90..22a485506 100644 --- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java +++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java @@ -1,5 +1,7 @@ package com.baiyue.print.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.web.CorrelationContext; import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.service.InboundMarkPdfService; @@ -24,13 +26,8 @@ public class InternalPdfController { static final String INTERNAL_TOKEN_HEADER = "X-Internal-Service-Token"; static final String PAGE_COUNT_HEADER = "X-Print-Page-Count"; - private final InboundMarkPdfService pdfService; - private final InternalServiceTokenGuard tokenGuard; - - public InternalPdfController(InboundMarkPdfService pdfService, InternalServiceTokenGuard tokenGuard) { - this.pdfService = pdfService; - this.tokenGuard = tokenGuard; - } + @Resource private InboundMarkPdfService pdfService; + @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity generateInboundMarks( diff --git a/print/src/main/java/com/baiyue/print/controller/InternalServiceTokenGuard.java b/print/src/main/java/com/baiyue/print/controller/InternalServiceTokenGuard.java index f32848c6b..d589400b8 100644 --- a/print/src/main/java/com/baiyue/print/controller/InternalServiceTokenGuard.java +++ b/print/src/main/java/com/baiyue/print/controller/InternalServiceTokenGuard.java @@ -1,5 +1,7 @@ package com.baiyue.print.controller; +import jakarta.annotation.PostConstruct; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import org.springframework.beans.factory.annotation.Value; @@ -11,9 +13,11 @@ import java.security.MessageDigest; /** Verifies the shared credential used by internal service callers. */ @Component public class InternalServiceTokenGuard { - private final byte[] expectedToken; + @Value("${baiyue.security.internal-service-token}") private String token; + private byte[] expectedToken; - public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) { + @PostConstruct + void initializeToken() { if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) { throw new IllegalStateException("INTERNAL_SERVICE_TOKEN must contain at least 32 bytes"); } diff --git a/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java b/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java index 182199be3..9a01129df 100644 --- a/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java +++ b/print/src/main/java/com/baiyue/print/controller/PrintRequestSizeFilter.java @@ -1,5 +1,7 @@ package com.baiyue.print.controller; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.web.CorrelationContext; @@ -28,13 +30,8 @@ import org.springframework.web.filter.OncePerRequestFilter; public class PrintRequestSizeFilter extends OncePerRequestFilter { private static final String PATH = "/internal/print/v1/pdfs/inbound-marks"; - private final PrintPdfProperties properties; - private final ObjectMapper objectMapper; - - public PrintRequestSizeFilter(PrintPdfProperties properties, ObjectMapper objectMapper) { - this.properties = properties; - this.objectMapper = objectMapper; - } + @Resource private PrintPdfProperties properties; + @Resource private ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, diff --git a/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java b/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java index d41eb4bf6..3b98e1e4e 100644 --- a/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java @@ -1,5 +1,7 @@ package com.baiyue.print.renderer; +import jakarta.annotation.Resource; + import com.baiyue.print.dto.InboundMarkPdfDtos; import com.google.zxing.common.BitMatrix; import java.io.IOException; @@ -24,11 +26,7 @@ public class InboundMarkV1Renderer { private static final float QR_LEFT_PT = (PAGE_WIDTH_PT - QR_SIZE_PT) / 2f; private static final float QR_BOTTOM_PT = PAGE_HEIGHT_PT - PdfUnits.mmToPt(84); - private final PdfQrRenderer qrRenderer; - - public InboundMarkV1Renderer(PdfQrRenderer qrRenderer) { - this.qrRenderer = qrRenderer; - } + @Resource private PdfQrRenderer qrRenderer; public void renderPage(PDDocument document, PDFont font, InboundMarkPdfDtos.Label label, BitMatrix qrMatrix) throws IOException { diff --git a/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java b/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java index c9a1338a1..4a0e4183e 100644 --- a/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java +++ b/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java @@ -1,5 +1,7 @@ package com.baiyue.print.service; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.print.config.PrintPdfProperties; @@ -30,18 +32,10 @@ public class InboundMarkPdfService { private static final Set FORBIDDEN_FILE_NAME_CHARACTERS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|'); - private final PrintPdfProperties properties; - private final PdfGenerationLimiter limiter; - private final InboundMarkV1Renderer renderer; - private final PdfQrRenderer qrRenderer; - - public InboundMarkPdfService(PrintPdfProperties properties, PdfGenerationLimiter limiter, - InboundMarkV1Renderer renderer, PdfQrRenderer qrRenderer) { - this.properties = properties; - this.limiter = limiter; - this.renderer = renderer; - this.qrRenderer = qrRenderer; - } + @Resource private PrintPdfProperties properties; + @Resource private PdfGenerationLimiter limiter; + @Resource private InboundMarkV1Renderer renderer; + @Resource private PdfQrRenderer qrRenderer; public GeneratedPdf generate(InboundMarkPdfDtos.Request request) { NormalizedRequest normalized = validateAndNormalize(request); diff --git a/print/src/main/java/com/baiyue/print/service/PdfGenerationLimiter.java b/print/src/main/java/com/baiyue/print/service/PdfGenerationLimiter.java index 113dd42ab..f18c0ca0f 100644 --- a/print/src/main/java/com/baiyue/print/service/PdfGenerationLimiter.java +++ b/print/src/main/java/com/baiyue/print/service/PdfGenerationLimiter.java @@ -1,5 +1,8 @@ package com.baiyue.print.service; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.print.config.PrintPdfProperties; @@ -13,10 +16,12 @@ import org.springframework.stereotype.Component; */ @Component public class PdfGenerationLimiter { - private final Semaphore semaphore; - private final Duration acquireTimeout; + @Resource private PrintPdfProperties properties; + private Semaphore semaphore; + private Duration acquireTimeout; - public PdfGenerationLimiter(PrintPdfProperties properties) { + @PostConstruct + void initializeLimiter() { if (properties.getMaxConcurrentGenerations() < 1) { throw new IllegalArgumentException("print.pdf.max-concurrent-generations must be positive"); } diff --git a/print/src/main/resources/application.yml b/print/src/main/resources/application.yml index 434aca3ff..5761abcbf 100644 --- a/print/src/main/resources/application.yml +++ b/print/src/main/resources/application.yml @@ -44,6 +44,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/print/src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java b/print/src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java index bf4199151..b75eb0c77 100644 --- a/print/src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java +++ b/print/src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java @@ -9,6 +9,7 @@ import com.baiyue.print.renderer.InboundMarkV1Renderer; import com.baiyue.print.renderer.PdfQrRenderer; import com.baiyue.print.service.InboundMarkPdfService; import com.baiyue.print.service.PdfGenerationLimiter; +import jakarta.annotation.Resource; import com.google.zxing.BinaryBitmap; import com.google.zxing.MultiFormatReader; import com.google.zxing.RGBLuminanceSource; @@ -29,16 +30,20 @@ import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; /** End-to-end renderer acceptance checks. These intentionally exercise the real service, not mocks. */ +@SpringJUnitConfig(classes = {InboundMarkPdfService.class, PrintPdfProperties.class, + PdfGenerationLimiter.class, InboundMarkV1Renderer.class, PdfQrRenderer.class}) class InboundMarkPdfAcceptanceTest { private static final String TEMPLATE = "INBOUND_MARK_V1"; private static final BigDecimal MM_100 = new BigDecimal("100"); + @Resource + private InboundMarkPdfService service; @Test void generatesOnePageWithExactSizeTextOrderEmbeddedChineseFontAndNoDiskArtifact(@TempDir Path tempDir) throws Exception { - InboundMarkPdfService service = service(); InboundMarkPdfDtos.Label label = label(1); String originalTmp = System.getProperty("java.io.tmpdir"); System.setProperty("java.io.tmpdir", tempDir.toString()); @@ -62,13 +67,13 @@ class InboundMarkPdfAcceptanceTest { @Test void preservesPageOrderAndDecodableQrOnFirstMiddleAndLastPages() throws Exception { List labels = labels(19); - assertPdf(service().generate(request(labels)).content(), labels); + assertPdf(service.generate(request(labels)).content(), labels); } @Test void generatesTwoHundredEighteenPagesWithExactDimensions() throws Exception { List labels = labels(218); - byte[] pdf = service().generate(request(labels)).content(); + byte[] pdf = service.generate(request(labels)).content(); try (PDDocument document = Loader.loadPDF(pdf)) { assertThat(document.getNumberOfPages()).isEqualTo(218); for (int i : List.of(0, 108, 217)) { @@ -82,7 +87,7 @@ class InboundMarkPdfAcceptanceTest { assumeTrue(Boolean.getBoolean("print.capacity.tests"), "Enable with -Dprint.capacity.tests=true for the 2000-page capacity acceptance test"); List labels = labels(2000); - byte[] pdf = service().generate(request(labels)).content(); + byte[] pdf = service.generate(request(labels)).content(); assertThat(pdf).startsWith("%PDF".getBytes(StandardCharsets.US_ASCII)); assertThat(pdf.length).isLessThan(32 * 1024 * 1024); try (PDDocument document = Loader.loadPDF(pdf)) { @@ -152,12 +157,6 @@ class InboundMarkPdfAcceptanceTest { return List.of(0, size / 2, size - 1); } - private InboundMarkPdfService service() { - PrintPdfProperties properties = new PrintPdfProperties(); - return new InboundMarkPdfService(properties, new PdfGenerationLimiter(properties), - new InboundMarkV1Renderer(new PdfQrRenderer()), new PdfQrRenderer()); - } - private InboundMarkPdfDtos.Request request(List labels) { return new InboundMarkPdfDtos.Request(TEMPLATE, new InboundMarkPdfDtos.PageSpec(MM_100, MM_100), "acceptance.pdf", labels); diff --git a/print/src/test/java/com/baiyue/print/config/PrintFontHealthIndicatorTest.java b/print/src/test/java/com/baiyue/print/config/PrintFontHealthIndicatorTest.java index dfc184941..6f0f07c85 100644 --- a/print/src/test/java/com/baiyue/print/config/PrintFontHealthIndicatorTest.java +++ b/print/src/test/java/com/baiyue/print/config/PrintFontHealthIndicatorTest.java @@ -4,20 +4,28 @@ import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.health.Status; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; class PrintFontHealthIndicatorTest { @Test void reportsUpForPackagedParseableFont() { - PrintPdfProperties properties = new PrintPdfProperties(); - - assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.UP); + context("fonts/NotoSansSC-Bold.ttf").run(context -> assertThat( + context.getBean(PrintFontHealthIndicator.class).health().getStatus()).isEqualTo(Status.UP)); } @Test void reportsDownForMissingFont() { - PrintPdfProperties properties = new PrintPdfProperties(); - properties.setFontResource("fonts/missing.ttf"); + context("fonts/missing.ttf").run(context -> assertThat( + context.getBean(PrintFontHealthIndicator.class).health().getStatus()).isEqualTo(Status.DOWN)); + } - assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.DOWN); + private ApplicationContextRunner context(String font) { + return new ApplicationContextRunner() + .withBean(PrintPdfProperties.class, () -> { + PrintPdfProperties properties = new PrintPdfProperties(); + properties.setFontResource(font); + return properties; + }) + .withBean(PrintFontHealthIndicator.class); } } diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java index 501c69a6e..e6188a3b5 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.web.GlobalExceptionHandler; import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.service.InboundMarkPdfService; +import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; @@ -12,6 +13,9 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.nio.charset.StandardCharsets; @@ -28,16 +32,18 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +@SpringJUnitConfig(classes = {InternalPdfController.class, InternalServiceTokenGuard.class}) +@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef") class InternalPdfControllerTest { private static final String TOKEN = "0123456789abcdef0123456789abcdef"; + @MockBean private InboundMarkPdfService service; + @Resource + private InternalPdfController controller; private MockMvc mockMvc; @BeforeEach void setUp() { - service = mock(InboundMarkPdfService.class); - InternalPdfController controller = new InternalPdfController( - service, new InternalServiceTokenGuard(TOKEN)); mockMvc = MockMvcBuilders.standaloneSetup(controller) .setControllerAdvice(new GlobalExceptionHandler()) .build(); diff --git a/print/src/test/java/com/baiyue/print/controller/InternalServiceTokenGuardTest.java b/print/src/test/java/com/baiyue/print/controller/InternalServiceTokenGuardTest.java index 2e3587ade..66b75adbf 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalServiceTokenGuardTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalServiceTokenGuardTest.java @@ -3,17 +3,23 @@ package com.baiyue.print.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@SpringJUnitConfig(InternalServiceTokenGuard.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef") class InternalServiceTokenGuardTest { private static final String TOKEN = "0123456789abcdef0123456789abcdef"; + @Resource private InternalServiceTokenGuard guard; @Test void acceptsOnlyConfiguredToken() { - InternalServiceTokenGuard guard = new InternalServiceTokenGuard(TOKEN); - guard.verify(TOKEN); assertThatThrownBy(() -> guard.verify(null)) .isInstanceOf(BizException.class) @@ -27,10 +33,14 @@ class InternalServiceTokenGuardTest { @Test void refusesUnsafeConfiguration() { - assertThatThrownBy(() -> new InternalServiceTokenGuard("too-short")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("at least 32 bytes"); - assertThatThrownBy(() -> new InternalServiceTokenGuard(null)) - .isInstanceOf(IllegalStateException.class); + new ApplicationContextRunner() + .withBean(PropertySourcesPlaceholderConfigurer.class) + .withBean(InternalServiceTokenGuard.class) + .withPropertyValues("baiyue.security.internal-service-token=too-short") + .run(context -> assertThat(context).hasFailed()); + new ApplicationContextRunner() + .withBean(PropertySourcesPlaceholderConfigurer.class) + .withBean(InternalServiceTokenGuard.class) + .run(context -> assertThat(context).hasFailed()); } } diff --git a/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java b/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java index 6902b5de0..96c2ab6a3 100644 --- a/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java +++ b/print/src/test/java/com/baiyue/print/controller/PrintRequestSizeFilterTest.java @@ -10,15 +10,20 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +@SpringJUnitConfig(classes = {PrintRequestSizeFilter.class, PrintPdfProperties.class, + PrintRequestSizeFilterTest.TestConfig.class}) class PrintRequestSizeFilterTest { - private PrintRequestSizeFilter filter; + @Resource private PrintRequestSizeFilter filter; + @Resource private PrintPdfProperties properties; @BeforeEach void setUp() { - PrintPdfProperties properties = new PrintPdfProperties(); properties.setMaxRequestBytes(10); - filter = new PrintRequestSizeFilter(properties, new ObjectMapper().findAndRegisterModules()); } @Test @@ -86,4 +91,12 @@ class PrintRequestSizeFilterTest { .contains("Request body exceeds 10 bytes") .contains("\"requestId\":\"request-123\""); } + + @Configuration + static class TestConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + } } diff --git a/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceGenerationTest.java index f68180b46..bc4dc7394 100644 --- a/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceGenerationTest.java @@ -7,6 +7,7 @@ import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.renderer.InboundMarkV1Renderer; import com.baiyue.print.renderer.PdfQrRenderer; import com.baiyue.print.renderer.PdfUnits; +import jakarta.annotation.Resource; import java.io.IOException; import java.math.BigDecimal; import java.util.List; @@ -14,17 +15,15 @@ import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +@SpringJUnitConfig(classes = {InboundMarkPdfService.class, PrintPdfProperties.class, + PdfGenerationLimiter.class, InboundMarkV1Renderer.class, PdfQrRenderer.class}) class InboundMarkPdfServiceGenerationTest { + @Resource + private InboundMarkPdfService service; @Test void generatesOneStrictHundredMillimeterPagePerLabelInRequestOrder() throws IOException { - PrintPdfProperties properties = new PrintPdfProperties(); - PdfQrRenderer qrRenderer = new PdfQrRenderer(); - InboundMarkPdfService service = new InboundMarkPdfService( - properties, - new PdfGenerationLimiter(properties), - new InboundMarkV1Renderer(qrRenderer), - qrRenderer); InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request( "INBOUND_MARK_V1", new InboundMarkPdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100.0")), diff --git a/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceValidationTest.java b/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceValidationTest.java index e8fc61668..bd01ddf16 100644 --- a/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceValidationTest.java +++ b/print/src/test/java/com/baiyue/print/service/InboundMarkPdfServiceValidationTest.java @@ -10,23 +10,20 @@ import com.baiyue.print.config.PrintPdfProperties; import com.baiyue.print.dto.InboundMarkPdfDtos; import com.baiyue.print.renderer.InboundMarkV1Renderer; import com.baiyue.print.renderer.PdfQrRenderer; +import jakarta.annotation.Resource; import java.math.BigDecimal; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +@SpringJUnitConfig(classes = {InboundMarkPdfService.class, PrintPdfProperties.class}) class InboundMarkPdfServiceValidationTest { - private InboundMarkPdfService service; - - @BeforeEach - void setUp() { - PrintPdfProperties properties = new PrintPdfProperties(); - service = new InboundMarkPdfService( - properties, - mock(PdfGenerationLimiter.class), - mock(InboundMarkV1Renderer.class), - mock(PdfQrRenderer.class)); - } + @MockBean private PdfGenerationLimiter limiter; + @MockBean private InboundMarkV1Renderer renderer; + @MockBean private PdfQrRenderer qrRenderer; + @Resource private InboundMarkPdfService service; @Test void rejectsUnsupportedPageSizeBeforeRendering() { diff --git a/print/src/test/java/com/baiyue/print/service/PdfGenerationLimiterTest.java b/print/src/test/java/com/baiyue/print/service/PdfGenerationLimiterTest.java index 9a2b8a2c2..3910ec68f 100644 --- a/print/src/test/java/com/baiyue/print/service/PdfGenerationLimiterTest.java +++ b/print/src/test/java/com/baiyue/print/service/PdfGenerationLimiterTest.java @@ -7,15 +7,16 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.print.config.PrintPdfProperties; import java.time.Duration; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +@SpringJUnitConfig(classes = {PdfGenerationLimiter.class, PdfGenerationLimiterTest.TestConfig.class}) class PdfGenerationLimiterTest { + @Resource private PdfGenerationLimiter limiter; @Test void rejectsWhenAllPermitsAreInUseAndReleasesPermitOnClose() { - PrintPdfProperties properties = new PrintPdfProperties(); - properties.setMaxConcurrentGenerations(1); - properties.setAcquireTimeout(Duration.ZERO); - PdfGenerationLimiter limiter = new PdfGenerationLimiter(properties); - PdfGenerationLimiter.Permit permit = limiter.acquire(); assertThatThrownBy(limiter::acquire) .isInstanceOfSatisfying(BizException.class, @@ -27,4 +28,15 @@ class PdfGenerationLimiterTest { // A second acquisition proves that close returned the only permit. } } + + @Configuration + static class TestConfig { + @Bean + PrintPdfProperties properties() { + PrintPdfProperties properties = new PrintPdfProperties(); + properties.setMaxConcurrentGenerations(1); + properties.setAcquireTimeout(Duration.ZERO); + return properties; + } + } } diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index adb308af7..85481bc52 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -19,6 +19,7 @@ | 平台快递公司增改查、启停与 XLSX 导入 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java`、`ExpressCompanyImportService.java`;模板 `/import-template`,导入 `/import` | | 平台托盘增改查 | `controller/PlatformPalletController.java` → `service/PalletApplicationService.java` → `repository/PalletRepository.java`;体积分托盘规则在 `service/PalletVolumeSplitter.java` | | 平台库位增改查、可用库位与容量 | `controller/PlatformLocationController.java` → `service/LocationApplicationService.java`、`LocationResourceService.java` → `repository/LocationRepository.java` | +| 海外仓阶梯费用 CRUD | `controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;客户主记录与阶梯明细分别落 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` | | Firstleg 托盘库位预占、确认和释放 | `controller/InternalPalletLocationController.java`(`/reservations`、`/confirm`、`/release`)→ `service/PalletLocationAllocationService.java`;只允许内部令牌调用 | | 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` | | 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` | diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index 35262c053..341c3a76a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -9,6 +9,8 @@ import com.baiyue.warehouse.service.PalletLocationAllocationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -25,13 +27,15 @@ import java.util.List; @RequestMapping("/internal/warehouse/v1/pallet-locations") @Tag(name = "托盘库位内部接口", description = "内部服务令牌保护的预占、确认和补偿释放") public class InternalPalletLocationController { - private final PalletLocationAllocationService service; - private final byte[] expectedToken; + @Resource + private PalletLocationAllocationService service; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; - public InternalPalletLocationController(PalletLocationAllocationService service, - @Value("${baiyue.security.internal-service-token}") String token) { - this.service = service; - this.expectedToken = token.getBytes(StandardCharsets.UTF_8); + @PostConstruct + void initializeToken() { + this.expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } @PostMapping("/reservations") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java index 78e63e315..38a4f959a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPartnerController.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.service.PartnerQueryService; import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; @@ -17,11 +18,13 @@ public class InternalPartnerController { @Resource private PartnerQueryService service; - private final byte[] expected; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expected; - public InternalPartnerController( - @Value("${baiyue.security.internal-service-token}") String token) { - this.expected=token.getBytes(StandardCharsets.UTF_8); + @PostConstruct + void initializeToken() { + this.expected = internalToken.getBytes(StandardCharsets.UTF_8); } @GetMapping("/{tenantId}/{id}/active") public ApiResponse active(@RequestHeader(value="X-Internal-Service-Token",required=false) String token, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java index 2f216a6c4..d6b4ccfdd 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.annotation.Resource; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; @@ -17,11 +18,13 @@ public class InternalWarehouseController { @Resource private WarehouseApplicationService service; - private final byte[] expected; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expected; - public InternalWarehouseController( - @Value("${baiyue.security.internal-service-token}") String token) { - this.expected = token.getBytes(StandardCharsets.UTF_8); + @PostConstruct + void initializeToken() { + this.expected = internalToken.getBytes(StandardCharsets.UTF_8); } @GetMapping("/{tenantId}/{id}/active") public ApiResponse active( diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java index 85b05d308..9deb3848b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java @@ -11,6 +11,7 @@ import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest; import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest; import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.service.ExpressCompanyQueryService; +import jakarta.annotation.Resource; import com.baiyue.warehouse.service.ExpressCompanyImportService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -47,13 +48,10 @@ import java.nio.charset.StandardCharsets; @RestController @RequestMapping("/api/platform/v1/express-companies") public class PlatformExpressCompanyController { - private final ExpressCompanyQueryService service; - private final ExpressCompanyImportService importService; - - public PlatformExpressCompanyController(ExpressCompanyQueryService service, ExpressCompanyImportService importService) { - this.service = service; - this.importService = importService; - } + @Resource + private ExpressCompanyQueryService service; + @Resource + private ExpressCompanyImportService importService; @GetMapping @PreAuthorize("hasAuthority('platform:express:list')") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java index 84302d65c..b63ccb1e4 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformLocationController.java @@ -11,6 +11,7 @@ import com.baiyue.warehouse.dto.LocationDtos.SaveRequest; import com.baiyue.warehouse.dto.LocationDtos.StatusRequest; import com.baiyue.warehouse.entity.LocationEntity; import com.baiyue.warehouse.service.LocationApplicationService; +import jakarta.annotation.Resource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -41,11 +42,8 @@ import java.util.List; @RequestMapping("/api/platform/v1/locations") @Tag(name = "平台库位管理", description = "按企业租户和仓库数据范围管理库位基础资料") public class PlatformLocationController { - private final LocationApplicationService service; - - public PlatformLocationController(LocationApplicationService service) { - this.service = service; - } + @Resource + private LocationApplicationService service; @GetMapping @PreAuthorize("hasAuthority('platform:location:list')") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java new file mode 100644 index 000000000..c79082c07 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeController.java @@ -0,0 +1,93 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.OverseasStorageFeeDtos; +import com.baiyue.warehouse.service.OverseasStorageFeeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.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; + +/** 平台海外仓阶梯费用管理接口。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/overseas-storage-fees") +@Tag(name = "海外仓阶梯费用", description = "按客户维护海外仓仓储费阶梯") +public class PlatformOverseasStorageFeeController { + @Resource + private OverseasStorageFeeService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-storage-fee:list')") + @Operation(summary = "分页查询海外仓阶梯费用") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @ParameterObject OverseasStorageFeeDtos.PageQuery query) { + requirePlatform(user); + return ApiResponse.success(service.page(query)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:overseas-storage-fee:list')") + @Operation(summary = "查询海外仓阶梯费用详情") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id) { + requirePlatform(user); + return ApiResponse.success(service.detail(id)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:overseas-storage-fee:create')") + @Operation(summary = "新增海外仓阶梯费用") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody OverseasStorageFeeDtos.CreateRequest request) { + requirePlatform(user); + return ApiResponse.success(service.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:overseas-storage-fee:edit')") + @Operation(summary = "编辑海外仓阶梯费用") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Min(1) long id, + @Valid @RequestBody OverseasStorageFeeDtos.UpdateRequest request) { + requirePlatform(user); + service.update(id, request); + return ApiResponse.success(null); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('platform:overseas-storage-fee:delete')") + @Operation(summary = "删除海外仓阶梯费用") + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Min(1) long id, + @RequestParam @Min(0) int version) { + requirePlatform(user); + service.delete(id, version); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java index 6da37badd..7e988a56b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.service.PalletApplicationService; +import jakarta.annotation.Resource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -33,11 +34,8 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/pallets") @Tag(name = "平台托盘管理", description = "按租户和仓库维护托盘主数据") public class PlatformPalletController { - private final PalletApplicationService service; - - public PlatformPalletController(PalletApplicationService service) { - this.service = service; - } + @Resource + private PalletApplicationService service; @GetMapping @PreAuthorize("hasAuthority('platform:pallet:list')") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java index d0bb4a40e..299bdab4c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java @@ -11,6 +11,7 @@ import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult; import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.service.PlatformPartnerQueryService; +import jakarta.annotation.Resource; import com.baiyue.warehouse.service.PartnerImportService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -47,13 +48,10 @@ import java.nio.charset.StandardCharsets; @RestController @RequestMapping("/api/platform/v1/partners") public class PlatformPartnerController { - private final PlatformPartnerQueryService service; - private final PartnerImportService importService; - - public PlatformPartnerController(PlatformPartnerQueryService service, PartnerImportService importService) { - this.service = service; - this.importService = importService; - } + @Resource + private PlatformPartnerQueryService service; + @Resource + private PartnerImportService importService; @GetMapping("/import-template") @PreAuthorize("hasAuthority('platform:counterparty:import')") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java index c60ea108a..4df87d18e 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java @@ -2,6 +2,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.service.WarehouseSchemaReadinessService; +import jakarta.annotation.Resource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; @@ -18,11 +19,8 @@ import java.util.Map; @RestController @RequestMapping("/api/platform/v1/warehouses") public class WarehouseHealthController { - private final WarehouseSchemaReadinessService readinessService; - - public WarehouseHealthController(WarehouseSchemaReadinessService readinessService) { - this.readinessService = readinessService; - } + @Resource + private WarehouseSchemaReadinessService readinessService; @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-service. / 返回 warehouse-service 的逻辑运行状态。") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java new file mode 100644 index 000000000..7bf0688d0 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasStorageFeeDtos.java @@ -0,0 +1,86 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 海外仓阶梯费用接口契约。 */ +public final class OverseasStorageFeeDtos { + private OverseasStorageFeeDtos() { + } + + @Schema(name = "OverseasStorageFeePageQuery", description = "海外仓阶梯费用分页条件") + public record PageQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + @Schema(description = "客户ID,不传时查询全部客户") @Positive Long customerId, + @Schema(description = "客户公司名称关键字") @Size(max = 128) String customerName + ) { + public PageQuery { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } + + @Schema(name = "OverseasStorageFeeTierRequest", description = "仓储费阶梯") + public record TierRequest( + @NotNull @Min(1) @Max(36500) Integer startDay, + @NotNull @Min(1) @Max(36500) Integer endDay, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6) + BigDecimal unitPrice + ) { + } + + @Schema(name = "OverseasStorageFeeCreateRequest", description = "新增海外仓阶梯费用") + public record CreateRequest( + @NotNull @Positive Long customerId, + @NotBlank @Size(max = 128) String customerName, + @NotBlank @Pattern(regexp = "^[A-Z]{3}$") String currency, + @NotEmpty @Size(max = 100) List<@Valid TierRequest> tiers + ) { + } + + @Schema(name = "OverseasStorageFeeUpdateRequest", description = "编辑海外仓阶梯费用") + public record UpdateRequest( + @NotNull @Positive Long customerId, + @NotBlank @Size(max = 128) String customerName, + @NotBlank @Pattern(regexp = "^[A-Z]{3}$") String currency, + @NotEmpty @Size(max = 100) List<@Valid TierRequest> tiers, + @NotNull @Min(0) Integer version + ) { + } + + @Schema(name = "OverseasStorageFeeTierView", description = "仓储费阶梯明细") + public record TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo) { + } + + @Schema(name = "OverseasStorageFeeView", description = "海外仓阶梯费用详情") + public record View( + Long id, + Long customerId, + String customerName, + String currency, + List tiers, + Integer version, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeRuleEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeRuleEntity.java new file mode 100644 index 000000000..9dde3a88b --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeRuleEntity.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.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; + +/** 客户维度的海外仓阶梯仓储费主记录。 */ +@Getter +@Setter +@TableName("wh_overseas_storage_fee_rule") +public class OverseasStorageFeeRuleEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private String customerName; + private String currency; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeTierEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeTierEntity.java new file mode 100644 index 000000000..e8a163796 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/OverseasStorageFeeTierEntity.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.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("wh_overseas_storage_fee_tier") +public class OverseasStorageFeeTierEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long tenantId; + private Long ruleId; + private Integer startDay; + private Integer endDay; + private BigDecimal unitPrice; + private Integer sortNo; +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeRuleMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeRuleMapper.java new file mode 100644 index 000000000..9c84c55da --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeRuleMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity; + +/** 海外仓阶梯费用主记录 Mapper。 */ +public interface OverseasStorageFeeRuleMapper extends BaseMapper { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeTierMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeTierMapper.java new file mode 100644 index 000000000..22a404b1d --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/OverseasStorageFeeTierMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity; + +/** 海外仓阶梯费用明细 Mapper。 */ +public interface OverseasStorageFeeTierMapper extends BaseMapper { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java index e60fe5d09..4d79dce41 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java @@ -6,17 +6,15 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery; import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.mapper.ExpressCompanyMapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; /** 快递公司主数据仓储;平台分页租户条件可选,资源读写仍强制租户边界。 */ @Repository public class ExpressCompanyRepository { - private final ExpressCompanyMapper mapper; - - public ExpressCompanyRepository(ExpressCompanyMapper mapper) { - this.mapper = mapper; - } + @Resource + private ExpressCompanyMapper mapper; public PageResult page(Long tenantId, ExpressCompanyPageQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java index d81887996..36493b54a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java @@ -13,6 +13,7 @@ import com.baiyue.warehouse.mapper.LocationMapper; import com.baiyue.warehouse.mapper.LocationOccupancyMapper; import com.baiyue.warehouse.mapper.LocationReservationMapper; import com.baiyue.warehouse.mapper.WarehouseMapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.util.Collection; @@ -21,18 +22,14 @@ import java.util.List; /** 库位持久化边界;平台分页条件可选,资源读写继续强制租户、仓库联合条件。 */ @Repository public class LocationRepository { - private final LocationMapper locations; - private final LocationReservationMapper reservations; - private final LocationOccupancyMapper occupancies; - private final WarehouseMapper warehouses; - - public LocationRepository(LocationMapper locations, LocationReservationMapper reservations, - LocationOccupancyMapper occupancies, WarehouseMapper warehouses) { - this.locations = locations; - this.reservations = reservations; - this.occupancies = occupancies; - this.warehouses = warehouses; - } + @Resource + private LocationMapper locations; + @Resource + private LocationReservationMapper reservations; + @Resource + private LocationOccupancyMapper occupancies; + @Resource + private WarehouseMapper warehouses; public WarehouseEntity findWarehouse(long tenantId, long warehouseId) { return warehouses.selectOne(new LambdaQueryWrapper() diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java new file mode 100644 index 000000000..95dab9de0 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/OverseasStorageFeeRepository.java @@ -0,0 +1,83 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.OverseasStorageFeeDtos; +import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity; +import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity; +import com.baiyue.warehouse.mapper.OverseasStorageFeeRuleMapper; +import com.baiyue.warehouse.mapper.OverseasStorageFeeTierMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.List; + +/** 海外仓阶梯费用持久化仓储。 */ +@Repository +public class OverseasStorageFeeRepository { + @Resource + private OverseasStorageFeeRuleMapper ruleMapper; + @Resource + private OverseasStorageFeeTierMapper tierMapper; + + public PageResult page(OverseasStorageFeeDtos.PageQuery query) { + LambdaQueryWrapper wrapper = + new LambdaQueryWrapper() + .eq(query.customerId() != null, OverseasStorageFeeRuleEntity::getTenantId, query.customerId()) + .like(StringUtils.hasText(query.customerName()), + OverseasStorageFeeRuleEntity::getCustomerName, query.customerName()) + .orderByDesc(OverseasStorageFeeRuleEntity::getUpdatedAt) + .orderByDesc(OverseasStorageFeeRuleEntity::getId); + return PageResult.from(ruleMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public OverseasStorageFeeRuleEntity find(long id) { + return ruleMapper.selectById(id); + } + + public boolean existsCustomer(long customerId, Long excludedId) { + return ruleMapper.selectCount(new LambdaQueryWrapper() + .eq(OverseasStorageFeeRuleEntity::getTenantId, customerId) + .ne(excludedId != null, OverseasStorageFeeRuleEntity::getId, excludedId)) > 0; + } + + public List findTiers(Collection ruleIds) { + if (ruleIds == null || ruleIds.isEmpty()) { + return List.of(); + } + return tierMapper.selectList(new LambdaQueryWrapper() + .in(OverseasStorageFeeTierEntity::getRuleId, ruleIds) + .orderByAsc(OverseasStorageFeeTierEntity::getRuleId) + .orderByAsc(OverseasStorageFeeTierEntity::getSortNo) + .orderByAsc(OverseasStorageFeeTierEntity::getId)); + } + + public void insertRule(OverseasStorageFeeRuleEntity entity) { + ruleMapper.insert(entity); + } + + public int updateRule(OverseasStorageFeeRuleEntity entity) { + return ruleMapper.updateById(entity); + } + + public void insertTiers(List tiers) { + tiers.forEach(tierMapper::insert); + } + + public void deleteTiers(long ruleId) { + tierMapper.delete(new LambdaQueryWrapper() + .eq(OverseasStorageFeeTierEntity::getRuleId, ruleId)); + } + + public int deleteRule(long id, int version) { + return ruleMapper.update(null, new LambdaUpdateWrapper() + .eq(OverseasStorageFeeRuleEntity::getId, id) + .eq(OverseasStorageFeeRuleEntity::getVersion, version) + .set(OverseasStorageFeeRuleEntity::getDeleted, true) + .set(OverseasStorageFeeRuleEntity::getVersion, version + 1)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java index b3f64d68e..b5e981f50 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.PalletEntity; import com.baiyue.warehouse.mapper.PalletMapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; @@ -16,11 +17,8 @@ import java.util.List; /** 托盘持久化仓储;平台分页支持跨租户筛选,资源操作始终包含租户和仓库边界。 */ @Repository public class PalletRepository { - private final PalletMapper mapper; - - public PalletRepository(PalletMapper mapper) { - this.mapper = mapper; - } + @Resource + private PalletMapper mapper; public PageResult page(Long tenantId, PalletRequests.PageQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java index 0e7691aff..9d7c91de3 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.mapper.PartnerMapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import java.util.List; @@ -15,8 +16,8 @@ import java.util.Map; @Repository public class PartnerRepository { - private final PartnerMapper mapper; - public PartnerRepository(PartnerMapper mapper) { this.mapper = mapper; } + @Resource + private PartnerMapper mapper; public List activeCandidates(long tenantId, String keyword, String type) { List typedIds = StringUtils.hasText(type) ? typedIds(tenantId, type) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 27cb86cb0..3950ee3fd 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -10,6 +10,7 @@ import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; import com.baiyue.warehouse.mapper.WarehouseAddressMapper; import com.baiyue.warehouse.mapper.WarehouseMapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; @@ -20,17 +21,12 @@ import java.util.List; */ @Repository public class WarehouseRepository { - private final WarehouseMapper warehouses; - private final WarehouseAddressMapper addresses; - private final ServiceCapabilityMapper capabilities; - - public WarehouseRepository(WarehouseMapper warehouses, - WarehouseAddressMapper addresses, - ServiceCapabilityMapper capabilities) { - this.warehouses = warehouses; - this.addresses = addresses; - this.capabilities = capabilities; - } + @Resource + private WarehouseMapper warehouses; + @Resource + private WarehouseAddressMapper addresses; + @Resource + private ServiceCapabilityMapper capabilities; public void insertWarehouse(WarehouseEntity warehouse) { warehouses.insert(warehouse); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java index 0f8dc6c8b..3ffea1057 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyImportService.java @@ -5,6 +5,7 @@ import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportError; import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportResult; import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.repository.ExpressCompanyRepository; +import jakarta.annotation.Resource; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; @@ -34,11 +35,8 @@ public class ExpressCompanyImportService { "快递公司编码*", "快递公司名称*", "客服电话", "官方网站", "轨迹查询地址模板", "状态*", "备注" }; - private final ExpressCompanyRepository repository; - - public ExpressCompanyImportService(ExpressCompanyRepository repository) { - this.repository = repository; - } + @Resource + private ExpressCompanyRepository repository; public byte[] template() { try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java index e800a4188..9af8b272e 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java @@ -8,6 +8,7 @@ import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery; import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest; import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.repository.ExpressCompanyRepository; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.annotation.Transactional; @@ -15,11 +16,8 @@ import org.springframework.transaction.annotation.Transactional; /** 平台快递公司管理服务。 */ @Service public class ExpressCompanyQueryService { - private final ExpressCompanyRepository repository; - - public ExpressCompanyQueryService(ExpressCompanyRepository repository) { - this.repository = repository; - } + @Resource + private ExpressCompanyRepository repository; public PageResult page(Long tenantId, ExpressCompanyPageQuery query) { return repository.page(tenantId, query); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java index d17c2f6af..6e6ff03d1 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationApplicationService.java @@ -10,6 +10,7 @@ import com.baiyue.warehouse.dto.LocationDtos.SaveRequest; import com.baiyue.warehouse.entity.LocationEntity; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.LocationRepository; +import jakarta.annotation.Resource; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,11 +20,8 @@ import java.util.List; /** 库位基础资料应用服务,负责平台分页、CRUD、租户仓库隔离和启停/删除约束。 */ @Service public class LocationApplicationService { - private final LocationRepository repository; - - public LocationApplicationService(LocationRepository repository) { - this.repository = repository; - } + @Resource + private LocationRepository repository; public PageResult page(Long tenantId, PageQuery query) { return repository.page(tenantId, query); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index 330d634e3..c259024ce 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -16,6 +16,7 @@ import com.baiyue.warehouse.entity.LocationOccupancyEntity; import com.baiyue.warehouse.entity.LocationReservationEntity; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.LocationRepository; +import jakarta.annotation.Resource; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,11 +44,8 @@ import java.util.Set; @Service @Validated public class LocationResourceService { - private final LocationRepository repository; - - public LocationResourceService(LocationRepository repository) { - this.repository = repository; - } + @Resource + private LocationRepository repository; public List findAvailable(@Min(1) long tenantId, @Min(1) long warehouseId, @Min(1) @Max(200) int limit) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java new file mode 100644 index 000000000..23f1e3b92 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/OverseasStorageFeeService.java @@ -0,0 +1,161 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.OverseasStorageFeeDtos; +import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity; +import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity; +import com.baiyue.warehouse.repository.OverseasStorageFeeRepository; +import jakarta.annotation.Resource; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** 海外仓阶梯费用管理服务。 */ +@Service +public class OverseasStorageFeeService { + @Resource + private OverseasStorageFeeRepository repository; + + public PageResult page(OverseasStorageFeeDtos.PageQuery query) { + PageResult source = repository.page(query); + Map> tiers = repository.findTiers( + source.records().stream().map(OverseasStorageFeeRuleEntity::getId).toList()) + .stream().collect(Collectors.groupingBy(OverseasStorageFeeTierEntity::getRuleId)); + return new PageResult<>(source.records().stream() + .map(rule -> view(rule, tiers.getOrDefault(rule.getId(), List.of()))) + .toList(), source.page(), source.size(), source.total(), source.pages()); + } + + public OverseasStorageFeeDtos.View detail(long id) { + OverseasStorageFeeRuleEntity rule = required(id); + return view(rule, repository.findTiers(List.of(id))); + } + + @Transactional + @BusinessAudit(event = "OVERSEAS_STORAGE_FEE_CREATE", resourceType = "OVERSEAS_STORAGE_FEE", + description = "新增海外仓阶梯费用", captureBefore = false) + public Long create(OverseasStorageFeeDtos.CreateRequest request) { + ensureCustomerUnique(request.customerId(), null); + List tiers = normalize(request.tiers()); + OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity(); + copy(rule, request.customerId(), request.customerName(), request.currency()); + try { + repository.insertRule(rule); + repository.insertTiers(tierEntities(rule.getId(), request.customerId(), tiers)); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "该客户已配置海外仓阶梯费用或阶梯区间重复"); + } + return rule.getId(); + } + + @Transactional + @BusinessAudit(event = "OVERSEAS_STORAGE_FEE_UPDATE", resourceType = "OVERSEAS_STORAGE_FEE", + resourceId = "#id", description = "编辑海外仓阶梯费用") + public void update(long id, OverseasStorageFeeDtos.UpdateRequest request) { + OverseasStorageFeeRuleEntity rule = required(id); + if (!rule.getVersion().equals(request.version())) { + throw new BizException(ResultCode.CONFLICT, "费用配置已被修改,请刷新后重试"); + } + ensureCustomerUnique(request.customerId(), id); + List tiers = normalize(request.tiers()); + copy(rule, request.customerId(), request.customerName(), request.currency()); + try { + if (repository.updateRule(rule) != 1) { + throw new BizException(ResultCode.CONFLICT, "费用配置已被并发修改,请刷新后重试"); + } + repository.deleteTiers(id); + repository.insertTiers(tierEntities(id, request.customerId(), tiers)); + } catch (DataIntegrityViolationException exception) { + throw new BizException(ResultCode.CONFLICT, "该客户已配置海外仓阶梯费用或阶梯区间重复"); + } + } + + @Transactional + @BusinessAudit(event = "OVERSEAS_STORAGE_FEE_DELETE", resourceType = "OVERSEAS_STORAGE_FEE", + resourceId = "#id", description = "删除海外仓阶梯费用") + public void delete(long id, int version) { + required(id); + repository.deleteTiers(id); + if (repository.deleteRule(id, version) != 1) { + throw new BizException(ResultCode.CONFLICT, "费用配置已被并发修改,请刷新后重试"); + } + } + + private OverseasStorageFeeRuleEntity required(long id) { + OverseasStorageFeeRuleEntity rule = repository.find(id); + if (rule == null) { + throw new BizException(ResultCode.NOT_FOUND, "海外仓阶梯费用不存在"); + } + return rule; + } + + private void ensureCustomerUnique(long customerId, Long excludedId) { + if (repository.existsCustomer(customerId, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "该客户已配置海外仓阶梯费用"); + } + } + + private static List normalize( + List source) { + if (source == null || source.isEmpty() || source.size() > 100) { + throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯费用明细必须为1至100组"); + } + if (source.stream().anyMatch(tier -> tier == null || tier.startDay() == null + || tier.endDay() == null || tier.unitPrice() == null + || tier.unitPrice().signum() <= 0)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯天数和仓储费标准必须为有效正数"); + } + List tiers = new ArrayList<>(source); + tiers.sort(Comparator.comparingInt(OverseasStorageFeeDtos.TierRequest::startDay)); + int expectedStart = 1; + for (OverseasStorageFeeDtos.TierRequest tier : tiers) { + if (tier.startDay() != expectedStart || tier.endDay() < tier.startDay()) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "阶梯必须从第1天开始,且区间连续、不能重叠或倒置"); + } + expectedStart = tier.endDay() + 1; + } + return List.copyOf(tiers); + } + + private static void copy(OverseasStorageFeeRuleEntity rule, long customerId, + String customerName, String currency) { + rule.setTenantId(customerId); + rule.setCustomerName(customerName.trim()); + rule.setCurrency(currency); + } + + private static List tierEntities( + long ruleId, long customerId, List source) { + List result = new ArrayList<>(source.size()); + for (int index = 0; index < source.size(); index++) { + OverseasStorageFeeDtos.TierRequest request = source.get(index); + OverseasStorageFeeTierEntity tier = new OverseasStorageFeeTierEntity(); + tier.setTenantId(customerId); + tier.setRuleId(ruleId); + tier.setStartDay(request.startDay()); + tier.setEndDay(request.endDay()); + tier.setUnitPrice(request.unitPrice()); + tier.setSortNo(index + 1); + result.add(tier); + } + return result; + } + + private static OverseasStorageFeeDtos.View view(OverseasStorageFeeRuleEntity rule, + List tiers) { + return new OverseasStorageFeeDtos.View(rule.getId(), rule.getTenantId(), rule.getCustomerName(), + rule.getCurrency(), tiers.stream().map(tier -> new OverseasStorageFeeDtos.TierView( + tier.getId(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice(), tier.getSortNo())) + .toList(), rule.getVersion(), rule.getCreatedAt(), rule.getUpdatedAt()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java index fa3941b16..05d810337 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -8,6 +8,7 @@ import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.PalletEntity; import com.baiyue.warehouse.repository.PalletRepository; import com.baiyue.warehouse.repository.WarehouseRepository; +import jakarta.annotation.Resource; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,13 +16,10 @@ import org.springframework.transaction.annotation.Transactional; /** 托盘主数据应用服务。 */ @Service public class PalletApplicationService { - private final PalletRepository repository; - private final WarehouseRepository warehouseRepository; - - public PalletApplicationService(PalletRepository repository, WarehouseRepository warehouseRepository) { - this.repository = repository; - this.warehouseRepository = warehouseRepository; - } + @Resource + private PalletRepository repository; + @Resource + private WarehouseRepository warehouseRepository; public PageResult page(Long tenantId, PalletRequests.PageQuery query) { PageResult source = repository.page(tenantId, query); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index 83e085d45..6188b1413 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -7,6 +7,7 @@ import com.baiyue.warehouse.dto.LocationDtos; import com.baiyue.warehouse.dto.PalletAllocationDtos; import com.baiyue.warehouse.entity.PalletEntity; import com.baiyue.warehouse.repository.PalletRepository; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,17 +22,12 @@ import java.util.List; public class PalletLocationAllocationService { /** 测量完成后默认推荐的普通托盘类型编码。 */ public static final String DEFAULT_PALLET_TYPE = "STANDARD"; - private final PalletRepository palletRepository; - private final PalletVolumeSplitter splitter; - private final LocationResourceService locationResourceService; - - public PalletLocationAllocationService(PalletRepository palletRepository, - PalletVolumeSplitter splitter, - LocationResourceService locationResourceService) { - this.palletRepository = palletRepository; - this.splitter = splitter; - this.locationResourceService = locationResourceService; - } + @Resource + private PalletRepository palletRepository; + @Resource + private PalletVolumeSplitter splitter; + @Resource + private LocationResourceService locationResourceService; @Transactional @BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "预占托盘和库位", captureBefore = false) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java index 6c5e994f3..84c43d9fc 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java @@ -5,6 +5,7 @@ import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportError; import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.repository.PartnerRepository; +import jakarta.annotation.Resource; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; @@ -39,11 +40,8 @@ public class PartnerImportService { "国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注" }; - private final PartnerRepository repository; - - public PartnerImportService(PartnerRepository repository) { - this.repository = repository; - } + @Resource + private PartnerRepository repository; public byte[] template() { try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java index 2f254ac6c..0dbcdacb0 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerQueryService.java @@ -4,13 +4,14 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.repository.PartnerRepository; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import java.util.List; @Service public class PartnerQueryService { - private final PartnerRepository repository; - public PartnerQueryService(PartnerRepository repository) { this.repository = repository; } + @Resource + private PartnerRepository repository; public List candidates(long tenantId, String keyword, String type) { if (type != null && !type.isBlank() && !"SHIPPER".equals(type) && !"CONSIGNEE".equals(type)) { throw new BizException(ResultCode.BAD_REQUEST, "候选类型只能是 SHIPPER 或 CONSIGNEE"); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java index 7fbb6d58a..1b83daed5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java @@ -8,6 +8,7 @@ import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.repository.PartnerRepository; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.annotation.Transactional; @@ -19,11 +20,8 @@ import java.util.Set; /** 平台往来单位管理服务。 */ @Service public class PlatformPartnerQueryService { - private final PartnerRepository repository; - - public PlatformPartnerQueryService(PartnerRepository repository) { - this.repository = repository; - } + @Resource + private PartnerRepository repository; public PageResult page(Long tenantId, PartnerPageQuery query) { return repository.page(tenantId, query); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index 3f41617ab..a652cce94 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -2,6 +2,7 @@ package com.baiyue.warehouse.service; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import java.util.List; @@ -21,14 +22,13 @@ public class WarehouseSchemaReadinessService { "SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0", "SELECT id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_type, max_weight_kg, shared_overflow, status, deleted, version FROM wh_location LIMIT 0", "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0", - "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0" + "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0", + "SELECT id, tenant_id, customer_name, currency, version FROM wh_overseas_storage_fee_rule LIMIT 0", + "SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0" ); - private final JdbcTemplate jdbcTemplate; - - public WarehouseSchemaReadinessService(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } + @Resource + private JdbcTemplate jdbcTemplate; public Readiness readiness() { try { diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml index e1e8d062d..0d0085386 100644 --- a/warehouse/src/main/resources/application.yml +++ b/warehouse/src/main/resources/application.yml @@ -35,6 +35,6 @@ logging: rollingpolicy: file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" + max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformExpressCompanyAllTenantQueryContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformExpressCompanyAllTenantQueryContractTest.java index eb5c71d1a..1bb18d4c5 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformExpressCompanyAllTenantQueryContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformExpressCompanyAllTenantQueryContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformExpressCompanyAllTenantQueryContractTest { @Test - void 分页租户参数可选但详情写入启停和导入仍然必填() { + void keepsTenantRequiredOutsidePaging() { Method page = method("page"); assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class); assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformLocationControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformLocationControllerContractTest.java index fcb4d2d1b..a72a9595d 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformLocationControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformLocationControllerContractTest.java @@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformLocationControllerContractTest { @Test - void 库位接口路径权限和Swagger说明完整() { + void shouldHandleCase01() { assertThat(PlatformLocationController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/platform/v1/locations"); assertMethod("page", GetMapping.class, "platform:location:list"); @@ -31,7 +31,7 @@ class PlatformLocationControllerContractTest { } @Test - void 分页查询的企业租户参数可选但写接口仍然必填() { + void keepsTenantRequiredForWrites() { Method page = method("page"); assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class); assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java index 39ada00c4..887d51245 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -17,7 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformMasterDataControllerContractTest { @Test - void 往来单位分页支持全部业务类型并拒绝未知类型() { + void shouldHandleCase01() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); for (String type : java.util.List.of( @@ -36,7 +36,7 @@ class PlatformMasterDataControllerContractTest { } @Test - void 仓库平台接口按读取新增编辑和启停权限隔离() { + void shouldHandleCase02() { RequestMapping mapping = PlatformWarehouseController.class.getAnnotation(RequestMapping.class); assertThat(mapping.value()).containsExactly("/api/platform/v1/warehouse-admin"); @@ -48,7 +48,7 @@ class PlatformMasterDataControllerContractTest { } @Test - void 往来单位分页路径权限和接口说明完整() throws Exception { + void shouldHandleCase03() throws Exception { assertContract(PlatformPartnerController.class, "platform:counterparty:list", "/api/platform/v1/partners"); assertMethod(PlatformPartnerController.class, "detail", GetMapping.class, "platform:counterparty:list"); assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create"); @@ -59,7 +59,7 @@ class PlatformMasterDataControllerContractTest { } @Test - void 快递公司分页路径权限和接口说明完整() throws Exception { + void shouldHandleCase04() throws Exception { assertContract(PlatformExpressCompanyController.class, "platform:express:list", "/api/platform/v1/express-companies"); assertMethod(PlatformExpressCompanyController.class, "detail", GetMapping.class, "platform:express:list"); assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java new file mode 100644 index 000000000..c93548db4 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOverseasStorageFeeControllerContractTest { + @Test + void exposesCrud() { + assertThat(PlatformOverseasStorageFeeController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/overseas-storage-fees"); + assertMethod("page", GetMapping.class, "platform:overseas-storage-fee:list"); + assertMethod("detail", GetMapping.class, "platform:overseas-storage-fee:list"); + assertMethod("create", PostMapping.class, "platform:overseas-storage-fee:create"); + assertMethod("update", PutMapping.class, "platform:overseas-storage-fee:edit"); + assertMethod("delete", DeleteMapping.class, "platform:overseas-storage-fee:delete"); + } + + private static void assertMethod(String name, Class mapping, String authority) { + Method method = Arrays.stream(PlatformOverseasStorageFeeController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); + assertThat(method.getAnnotation(mapping)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + authority + "')"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPalletControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPalletControllerContractTest.java index b187858ef..5f5d2d2f3 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPalletControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPalletControllerContractTest.java @@ -17,7 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformPalletControllerContractTest { @Test - void 托盘接口路径和按钮权限完整() { + void shouldHandleCase01() { assertThat(PlatformPalletController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/platform/v1/pallets"); assertMethod("page", GetMapping.class, "platform:pallet:list"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPartnerAllTenantQueryContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPartnerAllTenantQueryContractTest.java index cd4351ea9..e54b6f214 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPartnerAllTenantQueryContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPartnerAllTenantQueryContractTest.java @@ -15,7 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformPartnerAllTenantQueryContractTest { @Test - void 平台分页租户可选但详情写入和导入仍要求租户() { + void keepsTenantRequiredOutsidePaging() { Method page = method(PlatformPartnerController.class, "page"); assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class); assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); @@ -28,7 +28,7 @@ class PlatformPartnerAllTenantQueryContractTest { } @Test - void 分页链路使用可选租户而其他仓储方法保持原有强租户边界() throws Exception { + void keepsTenantScopeForOtherMethods() throws Exception { assertThat(PlatformPartnerQueryService.class.getMethod("page", Long.class, PartnerPageQuery.class)) .isNotNull(); assertThat(PartnerRepository.class.getMethod("page", Long.class, PartnerPageQuery.class)) @@ -40,7 +40,7 @@ class PlatformPartnerAllTenantQueryContractTest { } @Test - void 类型筛选在数据库内关联分页且租户条件可选() throws Exception { + void supportsOptionalTenantByType() throws Exception { Method method = PartnerMapper.class.getMethod("selectPartnerPageByType", Page.class, Long.class, String.class, String.class, String.class); String sql = String.join("\n", method.getAnnotation(Select.class).value()); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java index e1fb525ee..7d6ecd638 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java @@ -11,7 +11,7 @@ import static org.mockito.Mockito.when; class WarehouseDataAccessExceptionHandlerTest { @Test - void 数据源故障统一返回503并保留请求标识() { + void shouldHandleCase01() { HttpServletRequest request = mock(HttpServletRequest.class); when(request.getAttribute("requestId")).thenReturn("request-1"); when(request.getAttribute("traceId")).thenReturn("trace-1"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java index 70e64731a..4e707d0cf 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java @@ -1,20 +1,25 @@ package com.baiyue.warehouse.controller; import com.baiyue.warehouse.service.WarehouseSchemaReadinessService; +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 static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@SpringJUnitConfig(WarehouseHealthController.class) class WarehouseHealthControllerTest { + @MockBean private WarehouseSchemaReadinessService service; + @Resource private WarehouseHealthController controller; @Test - void 表结构漂移时状态接口返回503() { - WarehouseSchemaReadinessService service = mock(WarehouseSchemaReadinessService.class); + void shouldHandleCase01() { when(service.readiness()).thenReturn( new WarehouseSchemaReadinessService.Readiness(false, "DEGRADED", "需要执行迁移")); - var response = new WarehouseHealthController(service).status(); + var response = controller.status(); assertThat(response.getStatusCode().value()).isEqualTo(503); assertThat(response.getBody()).isNotNull(); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/mapper/LocationMapperContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/mapper/LocationMapperContractTest.java index 5f7f95484..509885728 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/mapper/LocationMapperContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/mapper/LocationMapperContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class LocationMapperContractTest { @Test - void 平台分页的企业和仓库条件均为可选且仍返回仓库快照() throws NoSuchMethodException { + void supportsOptionalPageFilters() throws NoSuchMethodException { Method page = LocationMapper.class.getMethod("selectLocationPage", Page.class, Long.class, Long.class, String.class, String.class, String.class, String.class); String pageSql = sql(page.getAnnotation(Select.class).value()); @@ -27,7 +27,7 @@ class LocationMapperContractTest { } @Test - void 可用库位按库区和库位数字自然顺序临近分配() throws NoSuchMethodException { + void shouldHandleCase01() throws NoSuchMethodException { String selectSql = sql(LocationMapper.class.getMethod( "selectAvailable", long.class, long.class, int.class).getAnnotation(Select.class).value()); String lockSql = sql(LocationMapper.class.getMethod( @@ -39,7 +39,7 @@ class LocationMapperContractTest { } @Test - void A0依靠唯一编码并发创建后加锁复用() throws NoSuchMethodException { + void shouldHandleCase02() throws NoSuchMethodException { Method insert = LocationMapper.class.getMethod( "insertSharedOverflowIfAbsent", long.class, long.class, long.class, long.class); Method lock = LocationMapper.class.getMethod("lockSharedOverflow", long.class, long.class); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java index 6e47cc898..879ce449f 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java @@ -11,6 +11,9 @@ import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -18,18 +21,20 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(ExpressCompanyRepository.class) class ExpressCompanyRepositoryTest { + @MockBean private ExpressCompanyMapper mapper; + @Resource private ExpressCompanyRepository repository; @BeforeAll - static void 初始化实体字段缓存() { + static void setUpCase01() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), ExpressCompanyEntity.class); } @Test - void 平台分页传入租户时按租户并支持筛选() { - ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); + void shouldHandleCase02() { when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); - new ExpressCompanyRepository(mapper).page(10001L, + repository.page(10001L, new ExpressCompanyPageQuery(1, 20, "顺丰", "ACTIVE")); @SuppressWarnings("unchecked") @@ -42,11 +47,10 @@ class ExpressCompanyRepositoryTest { } @Test - void 平台分页不传租户时查询全部企业并保留其他筛选() { - ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); + void pagesAllTenantsWithFilters() { when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); - new ExpressCompanyRepository(mapper).page(null, + repository.page(null, new ExpressCompanyPageQuery(1, 20, "顺丰", "ACTIVE")); @SuppressWarnings("unchecked") diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java new file mode 100644 index 000000000..7e692463c --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java @@ -0,0 +1,31 @@ +package com.baiyue.warehouse.repository; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasStorageFeeSqlContractTest { + @Test + void containsSchemaAndPermissions() throws IOException { + String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule") + .contains("CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier") + .contains("uk_overseas_fee_active_customer") + .contains("idx_overseas_fee_page") + .contains("idx_overseas_fee_customer_name") + .contains("uk_overseas_fee_tier_start") + .contains("idx_overseas_fee_tier_rule") + .contains("platform:overseas-storage-fee:list") + .contains("platform:overseas-storage-fee:create") + .contains("platform:overseas-storage-fee:edit") + .contains("platform:overseas-storage-fee:delete") + .contains("CALL unified_assert_overseas_storage_fee()") + .contains("海外仓阶梯费用表、索引和权限校验通过"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java index 1e79cc23d..cafcc873a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PalletRepositoryTest.java @@ -11,6 +11,9 @@ import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.util.Collection; @@ -20,37 +23,39 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(PalletRepository.class) class PalletRepositoryTest { + @MockBean private PalletMapper mapper; + @Resource private PalletRepository repository; @BeforeAll - static void 初始化实体字段缓存() { + static void initEntityMetadata() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), PalletEntity.class); } @Test - void 平台分页默认不限制企业和仓库() { + void pagesWithoutFilters() { Collection values = queryValues(null, null); assertThat(values).isEmpty(); } @Test - void 平台分页可单独按企业筛选() { + void pagesByTenant() { Collection values = queryValues(10001L, null); assertThat(values).containsExactly(10001L); } @Test - void 平台分页可同时按企业和仓库筛选() { + void pagesByTenantAndWarehouse() { Collection values = queryValues(10001L, 20L); assertThat(values).containsExactlyInAnyOrder(10001L, 20L); } @Test - void 平台分页可按全局唯一仓库筛选且写查询仍保留联合边界() { + void keepsWriteScopeForWarehouseFilter() { Collection pageValues = queryValues(null, 20L); assertThat(pageValues).containsExactly(20L); - PalletMapper mapper = mapper(); - new PalletRepository(mapper).findOwned(10001L, 20L, 30L); + repository.findOwned(10001L, 20L, 30L); @SuppressWarnings("unchecked") ArgumentCaptor> wrapper = ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(mapper).selectOne(wrapper.capture()); @@ -59,9 +64,9 @@ class PalletRepositoryTest { .containsExactlyInAnyOrder(10001L, 20L, 30L); } - private static Collection queryValues(Long tenantId, Long warehouseId) { - PalletMapper mapper = mapper(); - new PalletRepository(mapper).page(tenantId, + private Collection queryValues(Long tenantId, Long warehouseId) { + when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); + repository.page(tenantId, new PalletRequests.PageQuery(1, 20, warehouseId, null, null)); @SuppressWarnings("unchecked") ArgumentCaptor> wrapper = ArgumentCaptor.forClass(LambdaQueryWrapper.class); @@ -70,9 +75,4 @@ class PalletRepositoryTest { return wrapper.getValue().getParamNameValuePairs().values(); } - private static PalletMapper mapper() { - PalletMapper mapper = mock(PalletMapper.class); - when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); - return mapper; - } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java index d55620696..b7b3f8d72 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -13,18 +13,24 @@ import org.apache.ibatis.session.Configuration; import java.util.List; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +@SpringJUnitConfig(PartnerRepository.class) class PartnerRepositoryTest { - @BeforeAll static void 初始化实体字段缓存() { + @MockBean private PartnerMapper mapper; + @Resource private PartnerRepository repository; + @BeforeAll static void setUpCase01() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), PartnerEntity.class); } - @Test void 候选查询强制租户和启用状态() { - PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of()); + @Test void shouldHandleCase02() { + when(mapper.selectList(any())).thenReturn(List.of()); when(mapper.selectPartnerIdsByType(10001,"SHIPPER")).thenReturn(List.of(1L)); - new PartnerRepository(mapper).activeCandidates(10001,"示例","SHIPPER"); + repository.activeCandidates(10001,"示例","SHIPPER"); @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment(); assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L); @@ -34,32 +40,30 @@ class PartnerRepositoryTest { @Test @DisplayName("默认候选只包含发货单位和收货单位") void shouldLimitDefaultCandidatesToOrderPartyTypes() { - PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectPartnerIdsByType(10001L,"SHIPPER")).thenReturn(List.of(1L)); when(mapper.selectPartnerIdsByType(10001L,"CONSIGNEE")).thenReturn(List.of(2L,1L)); when(mapper.selectList(any())).thenReturn(List.of()); - new PartnerRepository(mapper).activeCandidates(10001L,null,null); + repository.activeCandidates(10001L,null,null); @SuppressWarnings("unchecked") ArgumentCaptor> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(mapper).selectList(wrapper.capture()); wrapper.getValue().getSqlSegment(); assertThat(wrapper.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L,2L); } - @Test void 详情查询强制租户和启用状态() { - PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectOne(any())).thenReturn(null); - new PartnerRepository(mapper).findActive(10002,9); + @Test void shouldHandleCase03() { + when(mapper.selectOne(any())).thenReturn(null); + repository.findActive(10002,9); @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(mapper).selectOne(c.capture()); c.getValue().getSqlSegment(); assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE"); } - @Test void 平台分页强制租户并支持筛选() { - PartnerMapper mapper=mock(PartnerMapper.class); + @Test void shouldHandleCase04() { when(mapper.selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"), eq("ACTIVE"), eq("示例"))).thenAnswer(invocation -> invocation.getArgument(0)); - new PartnerRepository(mapper).page(10001L, + repository.page(10001L, new PartnerPageQuery(1, 20, "示例", "CUSTOMER", "ACTIVE")); verify(mapper).selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"), @@ -67,12 +71,11 @@ class PartnerRepositoryTest { verify(mapper, never()).selectPartnerIdsByType(anyLong(), anyString()); } - @Test void 平台分页不传租户时查询全部企业并支持承运商类型筛选() { - PartnerMapper mapper=mock(PartnerMapper.class); + @Test void pagesAllTenantsByCarrier() { when(mapper.selectPartnerPageByType(any(Page.class), isNull(), eq("CARRIER"), eq("ACTIVE"), isNull())).thenAnswer(invocation -> invocation.getArgument(0)); - new PartnerRepository(mapper).page(null, + repository.page(null, new PartnerPageQuery(1, 20, null, "CARRIER", "ACTIVE")); verify(mapper).selectPartnerPageByType(any(Page.class), isNull(), eq("CARRIER"), diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 303e86d84..697f0fd70 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class WarehouseSchemaContractTest { @Test - void 仓库三张表包含实体全部持久化字段() throws IOException { + void shouldHandleCase01() throws IOException { String sql = read("warehouse.sql"); assertColumns(sql, "wh_warehouse", "warehouse_code", "warehouse_name", "warehouse_type", "country_code", "timezone", "contact_name", "contact_phone", "contact_email", "status", "remark"); @@ -22,7 +22,7 @@ class WarehouseSchemaContractTest { } @Test - void 往来单位表包含实体全部持久化字段() throws IOException { + void shouldHandleCase02() throws IOException { String sql = read("warehouse-partner.sql"); assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", @@ -33,7 +33,7 @@ class WarehouseSchemaContractTest { } @Test - void 快递公司表包含实体全部持久化字段() throws IOException { + void shouldHandleCase03() throws IOException { String sql = read("warehouse-express-company.sql"); assertColumns(sql, "wh_express_company", "company_code", "company_name", "service_phone", "website", "tracking_url_template", "status", "remark"); @@ -41,7 +41,7 @@ class WarehouseSchemaContractTest { } @Test - void 二期库位共用表覆盖基础资料预占占用和并发约束() throws IOException { + void shouldHandleCase04() throws IOException { String sql = read("phase2-pallet-location-schema.sql"); assertColumns(sql, "wh_location", "tenant_id", "warehouse_id", "zone_code", "zone_name", "location_code", "location_type", "max_weight_kg", "shared_overflow", "status", "remark", "deleted", "version"); @@ -59,7 +59,7 @@ class WarehouseSchemaContractTest { } @Test - void A0迁移允许共享库位多占用但仍限制托盘唯一活动占用() throws IOException { + void shouldHandleCase05() throws IOException { String sql = read("phase2-location-overflow-migration.sql"); assertThat(sql).contains("'wh_location', 'shared_overflow'") .contains("'wh_location_reservation', 'shared_overflow'") @@ -78,7 +78,7 @@ class WarehouseSchemaContractTest { } @Test - void 既有库增量迁移覆盖全部仓储实体表和列表索引() throws IOException { + void shouldHandleCase06() throws IOException { String sql = read("phase1-warehouse-schema-migration.sql"); assertThat(sql).contains("FROM information_schema.columns") .contains("CREATE PROCEDURE add_column_if_missing") diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java index 7f7a8abf5..a4cd8f174 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/ExpressCompanyImportServiceTest.java @@ -5,6 +5,9 @@ import com.baiyue.warehouse.repository.ExpressCompanyRepository; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -16,12 +19,13 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @DisplayName("快递公司批量导入") +@SpringJUnitConfig(ExpressCompanyImportService.class) class ExpressCompanyImportServiceTest { + @MockBean private ExpressCompanyRepository repository; + @Resource private ExpressCompanyImportService service; @Test @DisplayName("标准模板通过整批校验后写入全部数据") void shouldImportValidWorkbookAfterFullValidation() throws Exception { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); - ExpressCompanyImportService service = new ExpressCompanyImportService(repository); byte[] workbook = withRows(service.template(), new String[][] { {"SF", "顺丰速运", "95338", "https://www.sf-express.com", "https://example.com/{trackingNo}", "ACTIVE", "国内件"} }); @@ -39,8 +43,6 @@ class ExpressCompanyImportServiceTest { @Test @DisplayName("文件内重复名称时整批拒绝且不写入") void shouldRejectWholeWorkbookWhenAnyRowIsInvalid() throws Exception { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); - ExpressCompanyImportService service = new ExpressCompanyImportService(repository); byte[] workbook = withRows(service.template(), new String[][] { {"SF", "顺丰速运", "", "", "", "ACTIVE", ""}, {"SF2", "顺丰速运", "", "", "", "ACTIVE", ""} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java index 23a94a9ab..d8ee744c7 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationApplicationServiceTest.java @@ -7,6 +7,9 @@ import com.baiyue.warehouse.entity.LocationEntity; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.LocationRepository; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; @@ -18,11 +21,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +@SpringJUnitConfig(LocationApplicationService.class) class LocationApplicationServiceTest { + @MockBean private LocationRepository repository; + @Resource private LocationApplicationService service; @Test - void 平台分页不传企业和仓库时查询全部库位() { - LocationRepository repository = mock(LocationRepository.class); - LocationApplicationService service = new LocationApplicationService(repository); + void pagesAllLocations() { PageQuery query = pageQuery(null); service.page(null, query); @@ -32,9 +36,7 @@ class LocationApplicationServiceTest { } @Test - void 平台分页可只按企业筛选全部仓库() { - LocationRepository repository = mock(LocationRepository.class); - LocationApplicationService service = new LocationApplicationService(repository); + void filtersByTenant() { PageQuery query = pageQuery(null); service.page(10001L, query); @@ -43,9 +45,7 @@ class LocationApplicationServiceTest { } @Test - void 平台分页可同时按企业和仓库筛选() { - LocationRepository repository = mock(LocationRepository.class); - LocationApplicationService service = new LocationApplicationService(repository); + void filtersByTenantAndWarehouse() { PageQuery query = pageQuery(88L); service.page(10001L, query); @@ -54,9 +54,7 @@ class LocationApplicationServiceTest { } @Test - void 新增时仓库必须属于当前企业租户() { - LocationRepository repository = mock(LocationRepository.class); - LocationApplicationService service = new LocationApplicationService(repository); + void shouldHandleCase01() { assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null))) .isInstanceOf(BizException.class).hasMessageContaining("企业租户"); @@ -64,23 +62,19 @@ class LocationApplicationServiceTest { } @Test - void 同一租户仓库的库位编码必须唯一() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase02() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE")); when(repository.existsCode(10001L, 88L, "A1-01", null)).thenReturn(true); - LocationApplicationService service = new LocationApplicationService(repository); assertThatThrownBy(() -> service.create(10001L, request("AVAILABLE", null))) .isInstanceOf(BizException.class).hasMessageContaining("编码"); } @Test - void 活动预占或占用库位不能停用() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase03() { LocationEntity location = location("AVAILABLE", 3); when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location); when(repository.hasActiveResource(10001L, 9L)).thenReturn(true); - LocationApplicationService service = new LocationApplicationService(repository); assertThatThrownBy(() -> service.changeStatus(10001L, 88L, 9L, "DISABLED", 3, 7L)) .isInstanceOf(BizException.class).hasMessageContaining("不能停用"); @@ -88,10 +82,8 @@ class LocationApplicationServiceTest { } @Test - void 删除前必须先停用且没有活动资源() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase04() { when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location("AVAILABLE", 3)); - LocationApplicationService service = new LocationApplicationService(repository); assertThatThrownBy(() -> service.delete(10001L, 88L, 9L, 3, 7L)) .isInstanceOf(BizException.class).hasMessageContaining("先停用"); @@ -99,12 +91,10 @@ class LocationApplicationServiceTest { } @Test - void 条件更新失败统一转换为乐观锁冲突() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase05() { when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location("DISABLED", 3)); when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse("ACTIVE")); when(repository.changeStatus(10001L, 88L, 9L, "DISABLED", "AVAILABLE", 3, 7L)).thenReturn(0); - LocationApplicationService service = new LocationApplicationService(repository); assertThatThrownBy(() -> service.changeStatus(10001L, 88L, 9L, "AVAILABLE", 3, 7L)) .isInstanceOf(BizException.class).hasMessageContaining("刷新"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java index cce897c87..9310ba2e8 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -11,6 +11,9 @@ import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.LocationRepository; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -26,15 +29,16 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(LocationResourceService.class) class LocationResourceServiceTest { + @MockBean private LocationRepository repository; + @Resource private LocationResourceService service; @Test - void 普通库位不足时优先分配普通位并将剩余托盘共享到A0() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase01() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); when(repository.lockAvailable(10001L, 88L, 2)).thenReturn(List.of(location(9L, "A1"))); when(repository.lockOrCreateSharedOverflow(10001L, 88L, 7L)).thenReturn(overflow()); when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); - LocationResourceService service = new LocationResourceService(repository); var result = service.reserveAvailable(reserveCommand()); @@ -46,12 +50,10 @@ class LocationResourceServiceTest { } @Test - void 无普通库位时同一请求的所有不足目标复用同一个A0() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase02() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); when(repository.lockAvailable(10001L, 88L, 2)).thenReturn(List.of()); when(repository.lockOrCreateSharedOverflow(10001L, 88L, 7L)).thenReturn(overflow()); - LocationResourceService service = new LocationResourceService(repository); var result = service.reserveAvailable(reserveCommand()); @@ -62,14 +64,12 @@ class LocationResourceServiceTest { } @Test - void 单事务按托盘顺序锁定并条件预占库位() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase03() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); when(repository.lockAvailable(10001L, 88L, 2)) .thenReturn(List.of(location(9L, "A1"), location(10L, "A2"))); when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); when(repository.changeStatus(10001L, 88L, 10L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1); - LocationResourceService service = new LocationResourceService(repository); var result = service.reserveAvailable(reserveCommand()); @@ -81,8 +81,7 @@ class LocationResourceServiceTest { } @Test - void 已确认预占按占用事实幂等返回() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase04() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); LocationReservationEntity reservation = reservation("CONFIRMED"); when(repository.lockReservation(10001L, 101L)).thenReturn(reservation); @@ -94,7 +93,6 @@ class LocationResourceServiceTest { occupancy.setLocationId(9L); occupancy.setLocationCode("A1"); when(repository.findOccupancyByReservation(10001L, 101L)).thenReturn(occupancy); - LocationResourceService service = new LocationResourceService(repository); var result = service.confirmOccupied(confirmCommand()); @@ -103,15 +101,13 @@ class LocationResourceServiceTest { } @Test - void 确认占用使用预占归属和库位版本条件更新() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase05() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); LocationReservationEntity reservation = reservation("RESERVED"); when(repository.lockReservation(10001L, 101L)).thenReturn(reservation); when(repository.findOwned(10001L, 88L, 9L)).thenReturn(location(9L, "A1")); when(repository.changeStatus(10001L, 88L, 9L, "RESERVED", "OCCUPIED", 0, 7L)).thenReturn(1); when(repository.confirmReservation(reservation)).thenReturn(1); - LocationResourceService service = new LocationResourceService(repository); var result = service.confirmOccupied(confirmCommand()); @@ -121,8 +117,7 @@ class LocationResourceServiceTest { } @Test - void 确认A0占用时保持共享库位AVAILABLE() { - LocationRepository repository = mock(LocationRepository.class); + void shouldHandleCase06() { when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse()); LocationReservationEntity reservation = reservation("RESERVED"); reservation.setLocationId(99L); @@ -131,7 +126,6 @@ class LocationResourceServiceTest { when(repository.lockReservation(10001L, 101L)).thenReturn(reservation); when(repository.findOwned(10001L, 88L, 99L)).thenReturn(overflow()); when(repository.confirmReservation(reservation)).thenReturn(1); - LocationResourceService service = new LocationResourceService(repository); var result = service.confirmOccupied(confirmCommand()); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java new file mode 100644 index 000000000..e9cd06aef --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/OverseasStorageFeeServiceTest.java @@ -0,0 +1,110 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.OverseasStorageFeeDtos; +import com.baiyue.warehouse.entity.OverseasStorageFeeRuleEntity; +import com.baiyue.warehouse.entity.OverseasStorageFeeTierEntity; +import com.baiyue.warehouse.repository.OverseasStorageFeeRepository; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OverseasStorageFeeService.class) +class OverseasStorageFeeServiceTest { + @MockBean + private OverseasStorageFeeRepository repository; + @Resource + private OverseasStorageFeeService service; + + @Test + void createsRule() { + doAnswer(invocation -> { + OverseasStorageFeeRuleEntity rule = invocation.getArgument(0); + rule.setId(10L); + return null; + }).when(repository).insertRule(any()); + + Long id = service.create(new OverseasStorageFeeDtos.CreateRequest( + 10001L, "示例客户", "USD", List.of(tier(8, 30, "0.8"), tier(1, 7, "0.5")))); + + assertThat(id).isEqualTo(10L); + verify(repository).insertTiers(any()); + } + + @Test + void rejectsTierGap() { + OverseasStorageFeeDtos.CreateRequest request = new OverseasStorageFeeDtos.CreateRequest( + 10001L, "示例客户", "USD", List.of(tier(1, 7, "0.5"), tier(9, 30, "0.8"))); + + assertThatThrownBy(() -> service.create(request)).isInstanceOf(BizException.class); + verify(repository, never()).insertRule(any()); + } + + @Test + void rejectsDuplicateCustomer() { + when(repository.existsCustomer(10001L, null)).thenReturn(true); + OverseasStorageFeeDtos.CreateRequest request = new OverseasStorageFeeDtos.CreateRequest( + 10001L, "示例客户", "USD", List.of(tier(1, 30, "0.8"))); + + assertThatThrownBy(() -> service.create(request)).isInstanceOf(BizException.class); + } + + @Test + void pagesWithTiers() { + OverseasStorageFeeRuleEntity rule = rule(); + OverseasStorageFeeTierEntity tier = new OverseasStorageFeeTierEntity(); + tier.setId(20L); + tier.setRuleId(10L); + tier.setStartDay(1); + tier.setEndDay(30); + tier.setUnitPrice(new BigDecimal("0.8")); + tier.setSortNo(1); + OverseasStorageFeeDtos.PageQuery query = new OverseasStorageFeeDtos.PageQuery(1, 20, null, null); + when(repository.page(query)).thenReturn(new PageResult<>(List.of(rule), 1, 20, 1, 1)); + when(repository.findTiers(List.of(10L))).thenReturn(List.of(tier)); + + OverseasStorageFeeDtos.View result = service.page(query).records().get(0); + + assertThat(result.customerId()).isEqualTo(10001L); + assertThat(result.tiers()).singleElement().extracting(OverseasStorageFeeDtos.TierView::unitPrice) + .isEqualTo(new BigDecimal("0.8")); + } + + @Test + void detectsStaleUpdate() { + OverseasStorageFeeRuleEntity rule = rule(); + when(repository.find(10L)).thenReturn(rule); + OverseasStorageFeeDtos.UpdateRequest request = new OverseasStorageFeeDtos.UpdateRequest( + 10001L, "示例客户", "USD", List.of(tier(1, 30, "0.8")), 2); + + assertThatThrownBy(() -> service.update(10L, request)).isInstanceOf(BizException.class); + verify(repository, never()).updateRule(any()); + } + + private static OverseasStorageFeeDtos.TierRequest tier(int start, int end, String price) { + return new OverseasStorageFeeDtos.TierRequest(start, end, new BigDecimal(price)); + } + + private static OverseasStorageFeeRuleEntity rule() { + OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity(); + rule.setId(10L); + rule.setTenantId(10001L); + rule.setCustomerName("示例客户"); + rule.setCurrency("USD"); + rule.setVersion(1); + return rule; + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java index db6e13a46..b566bc9ae 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java @@ -7,19 +7,23 @@ import com.baiyue.warehouse.entity.PalletEntity; import com.baiyue.warehouse.repository.PalletRepository; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; 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; +@SpringJUnitConfig(PalletApplicationService.class) class PalletApplicationServiceTest { - private final PalletRepository repository = mock(PalletRepository.class); - private final WarehouseRepository warehouseRepository = mock(WarehouseRepository.class); - private final PalletApplicationService service = new PalletApplicationService(repository, warehouseRepository); + @MockBean private PalletRepository repository; + @MockBean private WarehouseRepository warehouseRepository; + @Resource private PalletApplicationService service; @Test - void 平台详情不暴露内部状态() { + void shouldHandleCase01() { PalletEntity entity = pallet("OCCUPIED"); when(repository.findOwned(10001, 20, 30)).thenReturn(entity); org.assertj.core.api.Assertions.assertThat(service.detail(10001, 20, 30)) @@ -27,12 +31,12 @@ class PalletApplicationServiceTest { } @Test - void 跨仓库托盘不可见() { + void shouldHandleCase02() { assertThatThrownBy(() -> service.detail(10001, 21, 30)).isInstanceOf(BizException.class); } @Test - void 平台分页允许不指定企业并保留分页结果() { + void pagesWithoutTenantFilter() { PalletRequests.PageQuery query = new PalletRequests.PageQuery(1, 20, null, null, null); when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(pallet("AVAILABLE")), 1, 20, 1, 1)); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java index c60348d62..3775ec830 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java @@ -6,6 +6,11 @@ import com.baiyue.warehouse.dto.PalletAllocationDtos; import com.baiyue.warehouse.entity.PalletEntity; import com.baiyue.warehouse.repository.PalletRepository; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -19,14 +24,19 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(classes = {PalletLocationAllocationService.class, PalletLocationAllocationServiceTest.TestConfig.class}) class PalletLocationAllocationServiceTest { - private final PalletRepository pallets = mock(PalletRepository.class); - private final LocationResourceService locations = mock(LocationResourceService.class); - private final PalletLocationAllocationService service = - new PalletLocationAllocationService(pallets, new PalletVolumeSplitter(), locations); + @MockBean private PalletRepository pallets; + @MockBean private LocationResourceService locations; + @Resource private PalletLocationAllocationService service; + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + @Bean PalletVolumeSplitter splitter() { return new PalletVolumeSplitter(); } + } @Test - void 并发抢占托盘时整批失败且不预占库位() { + void shouldHandleCase01() { when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of()); when(pallets.findAvailable(10001, 20, "木托盘", new BigDecimal("2"), 1)) .thenReturn(List.of(pallet(30, "TP-001"))); @@ -37,7 +47,7 @@ class PalletLocationAllocationServiceTest { } @Test - void 未指定类型时默认推荐普通托盘() { + void shouldHandleCase02() { when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of()); when(pallets.findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1)) .thenReturn(List.of(pallet(30, "TP-001"))); @@ -55,7 +65,7 @@ class PalletLocationAllocationServiceTest { } @Test - void 释放预占后托盘恢复可用并可再次预占() { + void shouldHandleCase03() { LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource( 101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED"); when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); @@ -70,7 +80,7 @@ class PalletLocationAllocationServiceTest { } @Test - void 已确认占用禁止补偿释放避免回滚入库事实() { + void shouldHandleCase04() { LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource( 101, 201L, 20, 30, "TP-001", 40, "A-01", false, "OCCUPIED"); when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); @@ -82,7 +92,7 @@ class PalletLocationAllocationServiceTest { } @Test - void 相同业务幂等重试直接返回已有预占() { + void shouldHandleCase05() { LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource( 101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED"); when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletVolumeSplitterTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletVolumeSplitterTest.java index 41c798f13..fab4a2bcc 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletVolumeSplitterTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletVolumeSplitterTest.java @@ -12,14 +12,14 @@ class PalletVolumeSplitterTest { private final PalletVolumeSplitter splitter = new PalletVolumeSplitter(); @Test - void 整除时不多拆托盘() { + void shouldHandleCase01() { var result = splitter.split(new BigDecimal("20"), new BigDecimal("2")); assertThat(result.palletCount()).isEqualTo(10); assertThat(result.allocations()).allMatch(item -> item.allocatedVolumeCbm().compareTo(new BigDecimal("2")) == 0); } @Test - void 非整除时向上拆分并保留尾盘体积() { + void shouldHandleCase02() { var result = splitter.split(new BigDecimal("20.1"), new BigDecimal("2")); assertThat(result.palletCount()).isEqualTo(11); assertThat(result.allocations().get(10).allocatedVolumeCbm()).isEqualByComparingTo("0.1"); @@ -28,7 +28,7 @@ class PalletVolumeSplitterTest { } @Test - void 拒绝空值零值负值和超大拆分() { + void shouldHandleCase03() { assertThatThrownBy(() -> splitter.split(null, BigDecimal.ONE)).isInstanceOf(BizException.class); assertThatThrownBy(() -> splitter.split(BigDecimal.ZERO, BigDecimal.ONE)).isInstanceOf(BizException.class); assertThatThrownBy(() -> splitter.split(BigDecimal.ONE, BigDecimal.ZERO)).isInstanceOf(BizException.class); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java index fb2cda4c7..cb6bf81b8 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java @@ -7,6 +7,9 @@ import org.apache.poi.ss.usermodel.Row; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -18,12 +21,13 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @DisplayName("往来单位批量导入") +@SpringJUnitConfig(PartnerImportService.class) class PartnerImportServiceTest { + @MockBean private PartnerRepository repository; + @Resource private PartnerImportService service; @Test @DisplayName("标准模板支持多类型并写入服务端数据范围") void shouldImportValidMultiTypeRow() throws Exception { - PartnerRepository repository = mock(PartnerRepository.class); - PartnerImportService service = new PartnerImportService(repository); byte[] file = withRows(service.template(), new String[][] { {"CUS-100", "测试单位", "CUSTOMER,SHIPPER", "张三", "13800138000", "test@example.com", "CN", "广东省", "深圳市", "南山区", "测试路 1 号", "518000", "ACTIVE", "测试备注"} @@ -43,8 +47,6 @@ class PartnerImportServiceTest { @Test @DisplayName("文件内任一错误时整批零写入并返回行号") void shouldRejectWholeBatchWhenAnyRowIsInvalid() throws Exception { - PartnerRepository repository = mock(PartnerRepository.class); - PartnerImportService service = new PartnerImportService(repository); byte[] file = withRows(service.template(), new String[][] { {"CUS-100", "测试单位一", "CUSTOMER", "张三", "13800138000", "", "CN", "", "深圳市", "", "测试路 1 号", "", "ACTIVE", ""}, {"CUS-100", "测试单位二", "UNKNOWN", "李四", "13900139000", "bad-email", "C", "", "", "", "", "", "ENABLED", ""} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java index e3941a8dc..352ee8bbf 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java @@ -5,45 +5,46 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.repository.PartnerRepository; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; 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.when; +@SpringJUnitConfig(PartnerQueryService.class) class PartnerQueryServiceTest { + @MockBean private PartnerRepository repository; + @Resource private PartnerQueryService service; @Test void unsupportedCandidateTypeReturnsBadRequest() { - PartnerRepository repository = mock(PartnerRepository.class); - assertThatThrownBy(() -> new PartnerQueryService(repository).candidates(10001L, null, "CUSTOMER")) + assertThatThrownBy(() -> service.candidates(10001L, null, "CUSTOMER")) .isInstanceOf(BizException.class).hasMessageContaining("SHIPPER"); } @Test void missingOrCrossTenantPartnerReturnsNotFound() { - PartnerRepository repository = mock(PartnerRepository.class); - - assertThatThrownBy(() -> new PartnerQueryService(repository).active(10001L, 7L)) + assertThatThrownBy(() -> service.active(10001L, 7L)) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.NOT_FOUND); } - @Test void 停用往来单位返回409() { - PartnerRepository repository = mock(PartnerRepository.class); + @Test void shouldHandleCase01() { PartnerEntity partner = new PartnerEntity(); partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("INACTIVE"); when(repository.findOwned(10001L, 7L)).thenReturn(partner); - assertThatThrownBy(() -> new PartnerQueryService(repository).active(10001L, 7L)) + assertThatThrownBy(() -> service.active(10001L, 7L)) .isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()) .isEqualTo(ResultCode.CONFLICT); } - @Test void 启用往来单位正常返回() { - PartnerRepository repository = mock(PartnerRepository.class); + @Test void shouldHandleCase02() { PartnerEntity partner = new PartnerEntity(); partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("ACTIVE"); when(repository.findOwned(10001L, 7L)).thenReturn(partner); - assertThat(new PartnerQueryService(repository).active(10001L, 7L)).isSameAs(partner); + assertThat(service.active(10001L, 7L)).isSameAs(partner); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java index f08363bdb..9885c2163 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java @@ -9,6 +9,9 @@ import com.baiyue.warehouse.repository.ExpressCompanyRepository; import com.baiyue.warehouse.repository.PartnerRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.dao.DataIntegrityViolationException; import static org.assertj.core.api.Assertions.assertThat; @@ -21,21 +24,26 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SpringJUnitConfig(classes = {PlatformPartnerQueryService.class, ExpressCompanyQueryService.class}) class PlatformMasterDataManagementServiceTest { + @MockBean private PartnerRepository partnerRepository; + @MockBean private ExpressCompanyRepository expressRepository; + @Resource private PlatformPartnerQueryService partnerService; + @Resource private ExpressCompanyQueryService expressService; @Test - void 往来单位详情强制租户隔离() { - PartnerRepository repository = mock(PartnerRepository.class); - PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + void shouldHandleCase01() { + PartnerRepository repository = partnerRepository; + PlatformPartnerQueryService service = partnerService; assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class); verify(repository).findOwned(10002L, 1L); } @Test - void 往来单位编码在租户内必须唯一() { - PartnerRepository repository = mock(PartnerRepository.class); + void shouldHandleCase02() { + PartnerRepository repository = partnerRepository; when(repository.existsCode(10001L, "CUS-001", null)).thenReturn(true); - PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + PlatformPartnerQueryService service = partnerService; assertThatThrownBy(() -> service.create(10001L, partnerRequest())) .isInstanceOf(BizException.class).hasMessageContaining("编码"); @@ -43,24 +51,24 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位并发重复编码统一返回业务冲突() { - PartnerRepository repository = mock(PartnerRepository.class); + void shouldHandleCase03() { + PartnerRepository repository = partnerRepository; doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); - PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + PlatformPartnerQueryService service = partnerService; assertThatThrownBy(() -> service.create(10001L, partnerRequest())) .isInstanceOf(BizException.class).hasMessageContaining("编码"); } @Test - void 往来单位新增写入租户和请求字段() { - PartnerRepository repository = mock(PartnerRepository.class); + void shouldHandleCase04() { + PartnerRepository repository = partnerRepository; doAnswer(invocation -> { PartnerEntity entity = invocation.getArgument(0); entity.setId(18L); return null; }).when(repository).insert(any()); - PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + PlatformPartnerQueryService service = partnerService; assertThat(service.create(10001L, partnerRequest())).isEqualTo(18L); @@ -75,13 +83,13 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位编辑保留归属并更新字段() { - PartnerRepository repository = mock(PartnerRepository.class); + void shouldHandleCase05() { + PartnerRepository repository = partnerRepository; PartnerEntity entity = new PartnerEntity(); entity.setId(19L); entity.setTenantId(10001L); when(repository.findOwned(10001L, 19L)).thenReturn(entity); - PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + PlatformPartnerQueryService service = partnerService; service.update(10001L, 19L, partnerRequest()); @@ -91,14 +99,14 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位停用保留主数据() { - PartnerRepository repository = mock(PartnerRepository.class); + void shouldHandleCase06() { + PartnerRepository repository = partnerRepository; PartnerEntity entity = new PartnerEntity(); entity.setId(8L); entity.setTenantId(10001L); entity.setStatus("ACTIVE"); when(repository.findOwned(10001L, 8L)).thenReturn(entity); - PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); + PlatformPartnerQueryService service = partnerService; service.changeStatus(10001L, 8L, "INACTIVE"); @@ -107,19 +115,19 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司详情强制租户隔离() { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); - ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + void shouldHandleCase07() { + ExpressCompanyRepository repository = expressRepository; + ExpressCompanyQueryService service = expressService; assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class); verify(repository).findOwned(10002L, 1L); } @Test - void 快递公司编码在租户内必须唯一() { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + void shouldHandleCase08() { + ExpressCompanyRepository repository = expressRepository; when(repository.existsCode(10001L, "SF", null)).thenReturn(true); - ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + ExpressCompanyQueryService service = expressService; assertThatThrownBy(() -> service.create(10001L, expressRequest())) .isInstanceOf(BizException.class).hasMessageContaining("编码"); @@ -129,9 +137,9 @@ class PlatformMasterDataManagementServiceTest { @Test @DisplayName("快递公司名称在租户内必须唯一") void shouldRequireUniqueExpressCompanyNameWithinTenant() { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + ExpressCompanyRepository repository = expressRepository; when(repository.existsName(10001L, "顺丰速运", null)).thenReturn(true); - ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + ExpressCompanyQueryService service = expressService; assertThatThrownBy(() -> service.create(10001L, expressRequest())) .isInstanceOf(BizException.class).hasMessageContaining("名称"); @@ -141,23 +149,23 @@ class PlatformMasterDataManagementServiceTest { @Test @DisplayName("快递公司并发重复编码统一返回业务冲突") void shouldReturnBusinessConflictForConcurrentDuplicateExpressCompanyCode() { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + ExpressCompanyRepository repository = expressRepository; doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); - ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + ExpressCompanyQueryService service = expressService; assertThatThrownBy(() -> service.create(10001L, expressRequest())) .isInstanceOf(BizException.class).hasMessageContaining("编码"); } @Test - void 快递公司新增写入租户和请求字段() { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + void shouldHandleCase09() { + ExpressCompanyRepository repository = expressRepository; doAnswer(invocation -> { ExpressCompanyEntity entity = invocation.getArgument(0); entity.setId(28L); return null; }).when(repository).insert(any()); - ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + ExpressCompanyQueryService service = expressService; assertThat(service.create(10001L, expressRequest())).isEqualTo(28L); @@ -170,13 +178,13 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司编辑保留归属并更新字段() { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + void shouldHandleCase10() { + ExpressCompanyRepository repository = expressRepository; ExpressCompanyEntity entity = new ExpressCompanyEntity(); entity.setId(29L); entity.setTenantId(10001L); when(repository.findOwned(10001L, 29L)).thenReturn(entity); - ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + ExpressCompanyQueryService service = expressService; service.update(10001L, 29L, expressRequest()); @@ -186,14 +194,14 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司停用保留主数据() { - ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); + void shouldHandleCase11() { + ExpressCompanyRepository repository = expressRepository; ExpressCompanyEntity entity = new ExpressCompanyEntity(); entity.setId(9L); entity.setTenantId(10001L); entity.setStatus("ACTIVE"); when(repository.findOwned(10001L, 9L)).thenReturn(entity); - ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); + ExpressCompanyQueryService service = expressService; service.changeStatus(10001L, 9L, "INACTIVE"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index ab6ddfee1..ead9bdefe 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -5,35 +5,34 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +@SpringJUnitConfig(WarehouseApplicationService.class) class WarehouseApplicationServiceTest { + @MockBean private WarehouseRepository repository; + @Resource private WarehouseApplicationService service; @Test - void 商户详情只返回启用且属于租户的仓库() { - WarehouseRepository repository=mock(WarehouseRepository.class); + void shouldHandleCase01() { WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE"); when(repository.findOwned(10001,1)).thenReturn(warehouse); when(repository.findAddresses(10001,1)).thenReturn(java.util.List.of()); when(repository.findCapabilities(10001,1)).thenReturn(java.util.List.of()); - WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); assertThat(service.activeDetail(10001,1).warehouse()).isSameAs(warehouse); verify(repository).findOwned(10001,1); } @Test - void 跨租户或不存在仓库返回404() { - WarehouseRepository repository=mock(WarehouseRepository.class); - WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); + void shouldHandleCase02() { assertThatThrownBy(() -> service.activeDetail(10002,1)).isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); } @Test - void 停用仓库返回409() { - WarehouseRepository repository=mock(WarehouseRepository.class); + void shouldHandleCase03() { WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("INACTIVE"); when(repository.findOwned(10001,1)).thenReturn(warehouse); - WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); assertThatThrownBy(() -> service.activeDetail(10001,1)).isInstanceOf(BizException.class) .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.CONFLICT); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java index 3fb2c64a2..60770b648 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java @@ -3,6 +3,9 @@ package com.baiyue.warehouse.service; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.BadSqlGrammarException; +import jakarta.annotation.Resource; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.sql.SQLException; @@ -12,12 +15,13 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +@SpringJUnitConfig(WarehouseSchemaReadinessService.class) class WarehouseSchemaReadinessServiceTest { + @MockBean private JdbcTemplate jdbcTemplate; + @Resource private WarehouseSchemaReadinessService service; @Test - void 全部表字段可查询时返回就绪() { - JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); - - var readiness = new WarehouseSchemaReadinessService(jdbcTemplate).readiness(); + void shouldHandleCase01() { + var readiness = service.readiness(); assertThat(readiness.ready()).isTrue(); assertThat(readiness.status()).isEqualTo("UP"); @@ -28,12 +32,11 @@ class WarehouseSchemaReadinessServiceTest { } @Test - void 缺表或缺列时返回降级而不是伪报正常() { - JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + void shouldHandleCase02() { doThrow(new BadSqlGrammarException("probe", "SELECT", new SQLException("missing"))) .when(jdbcTemplate).execute(anyString()); - var readiness = new WarehouseSchemaReadinessService(jdbcTemplate).readiness(); + var readiness = service.readiness(); assertThat(readiness.ready()).isFalse(); assertThat(readiness.status()).isEqualTo("DEGRADED");