This commit is contained in:
2026-08-05 11:46:07 +08:00
260 changed files with 6396 additions and 6599 deletions
+3 -2
View File
@@ -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/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。
@@ -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();
@@ -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}"
@@ -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) {
@@ -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
@@ -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<AuthAccount>()
@@ -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";
@@ -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,
+1 -1
View File
@@ -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}"
@@ -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);
}
}
}
@@ -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")));
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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");
@@ -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(); }
}
@@ -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();
@@ -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;
@@ -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='订单幂等记录';
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39 -30
View File
@@ -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`。
File diff suppressed because it is too large Load Diff
@@ -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()) {
@@ -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) {
@@ -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}"
@@ -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("令牌无效");
@@ -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("目标区域");
}
@@ -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<String, ExportProviderFeignClient> clients;
private final MeterRegistry meters;
public ExportProviderClient(IamExportProviderClient iam, OrderExportProviderClient order,
WarehouseExportProviderClient warehouse, FirstlegExportProviderClient firstleg,
MeterRegistry meters) {
private Map<String, ExportProviderFeignClient> 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<ExportBatchResponse> batch(String internalToken, String exportType,
@@ -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)
@@ -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()")
@@ -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')")
@@ -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<MessageExt>, 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);
@@ -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<MessageExt> {
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");
@@ -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)) {
@@ -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; }
@@ -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() {
@@ -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(),
@@ -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);
@@ -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));
@@ -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();
@@ -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);
@@ -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);
@@ -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) {
@@ -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)) {
@@ -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<String> 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);
@@ -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) {
@@ -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<String> 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("称重图片不能为空");
@@ -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<String> 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");
+1 -1
View File
@@ -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}"
@@ -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);
@@ -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}));
@@ -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(
@@ -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;
}
@@ -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));
}
@@ -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());
}
}
@@ -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> 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");
@@ -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(); }
}
}
@@ -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);
@@ -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");
@@ -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<String> 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(); }
}
}
@@ -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<ExportColumn> 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<Map<String,Object>> firstRows=IntStream.range(0,100).mapToObj(index -> Map.<String,Object>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(); }
}
}
@@ -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());
}
@@ -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);
@@ -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(
@@ -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();
}
}
@@ -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")
@@ -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')")
@@ -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 六行签名")
@@ -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')")
@@ -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')")
@@ -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")
@@ -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 = "测量审核通过")
@@ -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 = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。")
@@ -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); }
@@ -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<FulfillmentTaskEntity>()
@@ -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<InboundOrderLookupEntity>()
@@ -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<InboundOrderEntity>()
@@ -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<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
@@ -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,
@@ -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<InboundOrderEntity>()
@@ -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,
@@ -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) {
@@ -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,
@@ -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,
@@ -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,
@@ -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。
@@ -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) {
@@ -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);
@@ -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);
@@ -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) {
@@ -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<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
validateQuery(query);
@@ -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) {
@@ -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) {
@@ -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,
@@ -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(
@@ -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);
@@ -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) {
+1 -1
View File
@@ -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}"
@@ -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);
@@ -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())
@@ -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<Object> queryValues(Long tenantId, String ruleCode) {
CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class);
private Collection<Object> 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<Object> rateQueryValues(Long tenantId, String rateCode) {
CarrierFbaRateMapper rates = mock(CarrierFbaRateMapper.class);
private Collection<Object> 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));
}
}

Some files were not shown because too many files have changed in this diff Show More