格式修改

This commit is contained in:
2026-08-05 10:35:07 +08:00
parent 81c9c92f5d
commit 496fc58a90
241 changed files with 5600 additions and 2088 deletions
@@ -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,5 +1,8 @@
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;
@@ -25,15 +28,16 @@ import java.util.List;
*/
@Component
public class TokenSessionStore {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final String keyPrefix;
@Resource
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();
+32 -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,12 @@ 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`。
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,7 @@
package com.baiyue.firstleg.security;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@@ -9,11 +11,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;
}
@Resource
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));
}
}
@@ -18,6 +18,9 @@ import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.mockito.ArgumentCaptor;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -26,22 +29,22 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InboundOrderRepository.class)
class InboundOrderRepositoryTest {
@MockBean private InboundOrderMapper orders;
@MockBean private InboundTransportSnapshotMapper transports;
@MockBean private InboundPalletAllocationMapper pallets;
@MockBean private InboundFileMapper files;
@MockBean private InboundInternalNoteMapper notes;
@MockBean private InboundStatusHistoryMapper histories;
@Resource private InboundOrderRepository repository;
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void shouldApplyTenantCompanyAndWarehouseFilters() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class);
InboundOrderMapper orders = mock(InboundOrderMapper.class);
when(orders.selectPage(any(Page.class), any(Wrapper.class)))
.thenReturn(new Page<InboundOrderEntity>(1, 20));
InboundOrderRepository repository = new InboundOrderRepository(
orders,
mock(InboundTransportSnapshotMapper.class),
mock(InboundPalletAllocationMapper.class),
mock(InboundFileMapper.class),
mock(InboundInternalNoteMapper.class),
mock(InboundStatusHistoryMapper.class));
InboundOrderQuery query = new InboundOrderQuery(
null, null, null, null, null, null, null, null,
null, null, null, null, 20001L, 30001L, null, null, null, 1, 20);
@@ -69,8 +72,6 @@ class InboundOrderRepositoryTest {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
InboundPalletAllocationEntity.class);
InboundOrderMapper orders = mock(InboundOrderMapper.class);
InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class);
Page<InboundOrderEntity> page = new Page<>(1, 20);
InboundOrderEntity first = new InboundOrderEntity();
first.setId(1L);
@@ -84,13 +85,6 @@ class InboundOrderRepositoryTest {
allocation(1L, 1, "A0"),
allocation(1L, 2, "A2"),
allocation(2L, 1, "B1")));
InboundOrderRepository repository = new InboundOrderRepository(
orders,
mock(InboundTransportSnapshotMapper.class),
pallets,
mock(InboundFileMapper.class),
mock(InboundInternalNoteMapper.class),
mock(InboundStatusHistoryMapper.class));
var result = repository.page(10001L, new InboundOrderQuery(
null, null, null, null, null, null, null, null,
@@ -118,9 +112,6 @@ class InboundOrderRepositoryTest {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
InboundPalletAllocationEntity.class);
InboundOrderMapper orders = mock(InboundOrderMapper.class);
InboundTransportSnapshotMapper transports = mock(InboundTransportSnapshotMapper.class);
InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class);
Page<InboundOrderEntity> page = new Page<>(1, 20);
page.setRecords(java.util.List.of(order(1L, 10001L), order(2L, 10002L)));
when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
@@ -132,9 +123,6 @@ class InboundOrderRepositoryTest {
allocation(1L, 10001L, 1, "A1"),
allocation(2L, 10002L, 1, "B1"),
allocation(1L, 10002L, 2, "WRONG")));
InboundOrderRepository repository = new InboundOrderRepository(
orders, transports, pallets, mock(InboundFileMapper.class),
mock(InboundInternalNoteMapper.class), mock(InboundStatusHistoryMapper.class));
var result = repository.page(null, new InboundOrderQuery(
null, null, null, null, null, null, null, null,
@@ -3,8 +3,14 @@ package com.baiyue.firstleg.security;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.Clock;
import java.time.Instant;
@@ -13,19 +19,34 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@DisplayName("第三方测量请求签名校验")
@SpringJUnitConfig(classes = {MeasurementRequestAuthenticator.class,
MeasurementRequestAuthenticatorTest.TestConfig.class})
class MeasurementRequestAuthenticatorTest {
private static final Instant NOW = Instant.parse("2026-08-01T02:30:00Z");
private static final String SECRET = "test-secret-at-least-32-bytes-long";
@MockBean private NonceReplayGuard guard;
@Resource private MeasurementOpenApiProperties properties;
@Resource private MeasurementRequestAuthenticator authenticator;
@BeforeEach
void setUp() {
properties.setEnabled(true);
MeasurementOpenApiProperties.AppCredential credential = new MeasurementOpenApiProperties.AppCredential();
credential.setSecret(SECRET);
credential.setTenantId(10001L);
credential.setSubjectId(90001L);
credential.setWarehouseBindings(Map.of("CN-YW-01", 30001L));
properties.setApps(Map.of("device-provider", credential));
}
@Test
@DisplayName("合法签名按六行规范串校验并占用Nonce")
void shouldAcceptValidSignatureAndClaimNonce() {
MeasurementOpenApiProperties properties = properties();
NonceReplayGuard guard = (appKey, nonce, ttl) -> true;
MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator(
properties, guard, Clock.fixed(NOW, ZoneOffset.UTC));
when(guard.claim(any(), any(), any())).thenReturn(true);
String body = "{\"eventNo\":\"M1\"}";
String timestamp = String.valueOf(NOW.toEpochMilli());
String canonical = MeasurementRequestAuthenticator.canonicalString("POST",
@@ -46,12 +67,10 @@ class MeasurementRequestAuthenticatorTest {
@DisplayName("非法签名返回统一未认证业务码且不占用Nonce")
void shouldRejectInvalidSignatureBeforeClaimingNonce() {
int[] claims = {0};
NonceReplayGuard guard = (appKey, nonce, ttl) -> {
when(guard.claim(any(), any(), any())).thenAnswer(invocation -> {
claims[0]++;
return true;
};
MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator(
properties(), guard, Clock.fixed(NOW, ZoneOffset.UTC));
});
assertThatThrownBy(() -> authenticator.authenticate("POST", "/openapi/v1/inbound-measurements",
new MeasurementRequestHeaders("device-provider", String.valueOf(NOW.toEpochMilli()),
@@ -65,8 +84,7 @@ class MeasurementRequestAuthenticatorTest {
@Test
@DisplayName("相同应用重复Nonce被拒绝")
void shouldRejectReplayedNonce() {
MeasurementRequestAuthenticator authenticator = new MeasurementRequestAuthenticator(
properties(), (appKey, nonce, ttl) -> false, Clock.fixed(NOW, ZoneOffset.UTC));
when(guard.claim(any(), any(), any())).thenReturn(false);
String timestamp = String.valueOf(NOW.toEpochMilli());
String body = "{}";
String canonical = MeasurementRequestAuthenticator.canonicalString("POST",
@@ -81,15 +99,16 @@ class MeasurementRequestAuthenticatorTest {
.isEqualTo(ResultCode.CONFLICT));
}
private MeasurementOpenApiProperties properties() {
MeasurementOpenApiProperties properties = new MeasurementOpenApiProperties();
properties.setEnabled(true);
MeasurementOpenApiProperties.AppCredential credential = new MeasurementOpenApiProperties.AppCredential();
credential.setSecret(SECRET);
credential.setTenantId(10001L);
credential.setSubjectId(90001L);
credential.setWarehouseBindings(Map.of("CN-YW-01", 30001L));
properties.setApps(Map.of("device-provider", credential));
return properties;
@Configuration
static class TestConfig {
@Bean
MeasurementOpenApiProperties properties() {
return new MeasurementOpenApiProperties();
}
@Bean
Clock clock() {
return Clock.fixed(NOW, ZoneOffset.UTC);
}
}
}
@@ -3,7 +3,10 @@ package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -13,15 +16,15 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(AutomaticInboundCompletionService.class)
class AutomaticInboundCompletionServiceTest {
private final FbaPricingService pricing = mock(FbaPricingService.class);
private final InboundWorkflowRepository workflow = mock(InboundWorkflowRepository.class);
private final InboundCompletionOrchestrator completion = mock(InboundCompletionOrchestrator.class);
private final AutomaticInboundCompletionService service =
new AutomaticInboundCompletionService(pricing, workflow, completion);
@MockBean private FbaPricingService pricing;
@MockBean private InboundWorkflowRepository workflow;
@MockBean private InboundCompletionOrchestrator completion;
@Resource private AutomaticInboundCompletionService service;
@Test
void 客服审核通过后自动计费发布并完成入库() {
void shouldHandleCase01() {
FeeSnapshotResponse fee = fee(501L);
when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(workflow.findOwned(10001L, 88L)).thenReturn(
@@ -43,7 +46,7 @@ class AutomaticInboundCompletionServiceTest {
}
@Test
void 已完成入库的审核重放只返回最新费用() {
void shouldHandleCase02() {
FeeSnapshotResponse fee = fee(501L);
when(workflow.findOwned(10001L, 88L)).thenReturn(order("INBOUNDED", "SENT", 8));
when(pricing.latest(10001L, 88L)).thenReturn(fee);
@@ -58,7 +61,7 @@ class AutomaticInboundCompletionServiceTest {
}
@Test
void 中断在入库处理中时续跑完成而不重复计费() {
void shouldHandleCase03() {
FeeSnapshotResponse fee = fee(501L);
when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(workflow.findOwned(10001L, 88L)).thenReturn(
@@ -4,8 +4,11 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -20,18 +23,22 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("FBA计费基础资料版本")
@SpringJUnitConfig(FbaMasterDataService.class)
class FbaMasterDataServiceTest {
private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0);
@MockBean
private FbaPricingRepository repository;
@Resource
private FbaMasterDataService service;
@Test
@DisplayName("平台列表不传企业时委托仓储查询全部企业规则")
void listsRulesAcrossTenantsWithoutTenantFilter() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
List<CustomerChargeRuleEntity> expected = List.of(activeRule(AUGUST, null));
when(repository.listRules(null, "CR-1")).thenReturn(expected);
List<CustomerChargeRuleEntity> result =
new FbaMasterDataService(repository).listRules(null, "CR-1");
List<CustomerChargeRuleEntity> result = service.listRules(null, "CR-1");
assertThat(result).isSameAs(expected);
verify(repository).listRules(null, "CR-1");
@@ -40,7 +47,6 @@ class FbaMasterDataServiceTest {
@Test
@DisplayName("半开有效期首尾相接不视为重叠")
void acceptsAdjacentHalfOpenPeriods() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
CustomerChargeRuleEntity old = activeRule(AUGUST.minusMonths(1), AUGUST);
when(repository.listRules(1L, null)).thenReturn(List.of(old));
when(repository.listRules(1L, "CR-1")).thenReturn(List.of(old));
@@ -48,8 +54,8 @@ class FbaMasterDataServiceTest {
doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(10L); return null; })
.when(repository).insertRule(any());
CustomerChargeRuleEntity created = new FbaMasterDataService(repository)
.createRuleVersion(1L, 2L, request(AUGUST, AUGUST.plusMonths(1)));
CustomerChargeRuleEntity created = service.createRuleVersion(
1L, 2L, request(AUGUST, AUGUST.plusMonths(1)));
assertThat(created.getId()).isEqualTo(10L);
assertThat(created.getRuleVersion()).isEqualTo(2);
@@ -58,12 +64,11 @@ class FbaMasterDataServiceTest {
@Test
@DisplayName("任意重叠有效期在保存阶段拒绝避免多规则匹配")
void rejectsOverlappingPeriods() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
when(repository.listRules(1L, null)).thenReturn(List.of(
activeRule(AUGUST, AUGUST.plusMonths(1))));
assertThatThrownBy(() -> new FbaMasterDataService(repository)
.createRuleVersion(1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2))))
assertThatThrownBy(() -> service.createRuleVersion(
1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2))))
.isInstanceOf(BizException.class)
.hasMessageContaining("有效期重叠");
}
@@ -10,9 +10,14 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.Clock;
@@ -35,16 +40,19 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("FBA费用状态机")
@SpringJUnitConfig(classes = {FbaPricingService.class, FbaPricingServiceTest.TestConfig.class})
class FbaPricingServiceTest {
private static final long TENANT = 10001L;
private static final long OPERATOR = 20001L;
private static final long INBOUND = 30001L;
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
@MockBean private FbaPricingRepository repository;
@Resource private FbaPricingService service;
@Test
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
FbaPricingRepository repository = readyRepository();
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0001");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00");
@@ -52,7 +60,7 @@ class FbaPricingServiceTest {
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
assertThat(response.convertedQuantity()).isEqualByComparingTo("167.017");
assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017");
@@ -64,7 +72,7 @@ class FbaPricingServiceTest {
@Test
@DisplayName("不包税CBM按配置系数折算且精度边界向上取整")
void calculatesTaxExcludedCbmAtRoundingBoundary() {
FbaPricingRepository repository = readyRepository();
readyRepository();
InboundPricingEntity inbound = inbound("363.001", "0.5000");
CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING");
CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00");
@@ -72,7 +80,7 @@ class FbaPricingServiceTest {
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate));
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
assertThat(response.convertedQuantity()).isEqualByComparingTo("1.0001");
assertThat(response.chargeableQuantity()).isEqualByComparingTo("1.0001");
@@ -82,12 +90,12 @@ class FbaPricingServiceTest {
@Test
@DisplayName("审核未通过的测量快照禁止计费")
void rejectsUnapprovedMeasurementSnapshot() {
FbaPricingRepository repository = readyRepository();
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
inbound.setMeasureReviewStatus("REJECTED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
.isInstanceOf(BizException.class)
.hasMessageContaining("审核通过");
verify(repository, never()).matchRules(any(), any());
@@ -96,25 +104,25 @@ class FbaPricingServiceTest {
@Test
@DisplayName("无客户规则和多客户规则都明确拒绝")
void rejectsMissingAndAmbiguousRules() {
FbaPricingRepository missing = readyRepository();
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
when(missing.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(missing.matchRules(inbound, NOW)).thenReturn(List.of());
assertThatThrownBy(() -> service(missing).calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of());
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则");
FbaPricingRepository ambiguous = readyRepository();
when(ambiguous.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(ambiguous.matchRules(inbound, NOW)).thenReturn(List.of(
org.mockito.Mockito.reset(repository);
readyRepository();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(
rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING")));
assertThatThrownBy(() -> service(ambiguous).calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
.isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则");
}
@Test
@DisplayName("同一幂等键重复试算直接返回原费用快照")
void returnsOriginalSnapshotForRepeatedCalculation() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
@@ -122,7 +130,7 @@ class FbaPricingServiceTest {
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
when(repository.findBaseItem(TENANT, 501L)).thenReturn(item);
FeeSnapshotResponse response = service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
assertThat(response.feeId()).isEqualTo(501L);
verify(repository, never()).matchRules(any(), any());
@@ -132,7 +140,6 @@ class FbaPricingServiceTest {
@Test
@DisplayName("重复发送只返回首次结果且不会重复写Outbox")
void sendIsIdempotentAndDoesNotDuplicateOutbox() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
InboundPricingEntity inbound = inbound("10", "1");
InboundFeeEntity confirmed = savedFee("CONFIRMED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -148,7 +155,6 @@ class FbaPricingServiceTest {
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
.when(repository).insertIdempotency(any());
FbaPricingService service = service(repository);
service.send(TENANT, OPERATOR, INBOUND, "send-once");
service.send(TENANT, OPERATOR, INBOUND, "send-once");
@@ -159,12 +165,11 @@ class FbaPricingServiceTest {
@Test
@DisplayName("同一幂等键用于不同试算时点会冲突")
void rejectsReusedIdempotencyKeyWithDifferentRequest() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
completed.setRequestHash("different");
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
assertThatThrownBy(() -> service(repository).calculate(TENANT, OPERATOR, INBOUND, NOW, "same"))
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same"))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
@@ -173,7 +178,6 @@ class FbaPricingServiceTest {
@Test
@DisplayName("已发布费用可生成新版本且不回退已入库状态")
void adjustsPublishedFeeWithoutRollingBackInbound() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("INBOUNDED");
inbound.setFeeStatus("SENT");
@@ -195,7 +199,7 @@ class FbaPricingServiceTest {
when(repository.completeIdempotency(any(), eq(502L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 502L)).thenAnswer(invocation -> inserted.get());
FeeSnapshotResponse response = service(repository).adjustPublished(TENANT, OPERATOR, INBOUND,
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1");
@@ -208,8 +212,7 @@ class FbaPricingServiceTest {
verify(repository).insertOutbox(any());
}
private FbaPricingRepository readyRepository() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
private void readyRepository() {
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
.when(repository).insertIdempotency(any());
@@ -218,12 +221,14 @@ class FbaPricingServiceTest {
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1);
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any())).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
return repository;
}
private FbaPricingService service(FbaPricingRepository repository) {
return new FbaPricingService(repository,
Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai")));
@Configuration
static class TestConfig {
@Bean
Clock clock() {
return Clock.fixed(Instant.parse("2026-08-03T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
}
}
private InboundPricingEntity inbound(String weight, String volume) {
@@ -6,9 +6,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.repository.FulfillmentTaskRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
@@ -19,24 +22,24 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("履约任务服务")
@SpringJUnitConfig(FulfillmentTaskService.class)
class FulfillmentTaskServiceTest {
@MockBean private FulfillmentTaskRepository repository;
@Resource private FulfillmentTaskService service;
@Test
@DisplayName("列表查询保留租户和分页条件")
void shouldPreserveTenantAndPaginationInPageQuery() {
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 1, 20);
PageResult<FulfillmentTaskEntity> expected = new PageResult<>(List.of(), 1, 20, 0, 0);
when(repository.page(10001L, query)).thenReturn(expected);
assertThat(service(repository).page(10001L, query)).isSameAs(expected);
assertThat(service.page(10001L, query)).isSameAs(expected);
verify(repository).page(10001L, query);
}
@Test
@DisplayName("详情和按订单查询不存在时返回404")
void shouldReturnNotFoundForMissingTaskAndOrder() {
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
FulfillmentTaskService service = service(repository);
assertThatThrownBy(() -> service.detail(10001L, 1L))
.isInstanceOf(BizException.class)
@@ -51,20 +54,14 @@ class FulfillmentTaskServiceTest {
@Test
@DisplayName("查询分配历史前先校验任务归属租户")
void shouldVerifyTaskTenantBeforeLoadingAssignmentHistory() {
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
FulfillmentTaskEntity task = new FulfillmentTaskEntity();
when(repository.findOwned(10001L, 1L)).thenReturn(task);
PageResult<com.baiyue.firstleg.entity.AssignmentHistoryEntity> expected =
new PageResult<>(List.of(), 1, 20, 0, 0);
when(repository.pageHistory(10001L, 1L, 1, 20)).thenReturn(expected);
assertThat(service(repository).assignmentHistory(10001L, 1L, 1, 20)).isSameAs(expected);
assertThat(service.assignmentHistory(10001L, 1L, 1, 20)).isSameAs(expected);
verify(repository).findOwned(10001L, 1L);
}
private FulfillmentTaskService service(FulfillmentTaskRepository repository) {
FulfillmentTaskService service = new FulfillmentTaskService();
ReflectionTestUtils.setField(service, "repository", repository);
return service;
}
}

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