diff --git a/AGENTS.md b/AGENTS.md index 959c2a9a9..c3d67900d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,9 @@ |---|---|---| | `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` | | `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `api/`、`exception/`、`model/`、`mybatis/` | -| `common-web/` | 全局异常与请求链路标识 | `GlobalExceptionHandler.java`、`CorrelationIdFilter.java` | +| `common-web/` | 全局异常、请求链路标识、长整型 JSON 字符串序列化 | `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` | | `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.java` | -| `common-openapi/` | SpringDoc 自动配置 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` | +| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` | | `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` | | `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` | | `gateway/` | 路由、CORS、入口安全、访问域隔离 | `config/`、`filter/`、`src/main/resources/application*.yml` | @@ -23,7 +23,7 @@ | `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` | | `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` | | `warehouse/` | 仓库、地址、服务能力、往来单位与快递公司主数据 | `controller/`、`WarehouseApplicationService.java`、`PlatformPartnerQueryService.java`、`ExpressCompanyQueryService.java` | -| `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;元数据表结构尚未接入 | +| `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;启动时校验存储目录,磁盘故障返回 `S0503`,元数据表结构尚未接入 | | `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 | | `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 | | `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` | @@ -36,7 +36,9 @@ - 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。 - `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。 - 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。 -- 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`。 +- 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。 +- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。 +- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。 - 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl -am test`。 - `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。 @@ -45,4 +47,5 @@ - Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。 - 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。 - 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。 +- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。 - 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。 diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index 1180692cb..07dda406a 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.repository.AuthAccountRepository; 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.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.util.ReflectionTestUtils; @@ -15,6 +16,7 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +@DisplayName("账号管理服务") class AccountManagementServiceTest { private final AuthAccountRepository repository = mock(AuthAccountRepository.class); private final TokenSessionStore sessions = mock(TokenSessionStore.class); @@ -31,7 +33,7 @@ class AccountManagementServiceTest { when(repository.findById(10L)).thenReturn(account); } - @Test void 修改密码校验旧密码并撤销全部会话() { + @Test void changePasswordValidatesCurrentPasswordAndRevokesAllSessions() { service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2")); assertTrue(encoder.matches("NewPass2", account.getPasswordHash())); assertEquals(3, account.getTokenVersion()); @@ -39,20 +41,20 @@ class AccountManagementServiceTest { verify(sessions).revokeAll(10L); } - @Test void 原密码错误不得写入() { + @Test void changePasswordDoesNotPersistWhenCurrentPasswordIsInvalid() { assertThrows(BizException.class, () -> service.changePassword(10L, new ChangePasswordRequest("WrongPass1", "NewPass2"))); verify(repository, never()).save(any()); verify(sessions, never()).revokeAll(any()); } - @Test void 停用账号撤销全部会话() { + @Test void disableAccountRevokesAllSessions() { service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED")); assertEquals("DISABLED", account.getAccountStatus()); verify(sessions).revokeAll(10L); } - @Test void 创建平台账号保存平台应用域且密码仅保存摘要() { + @Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() { when(repository.findById(20L)).thenReturn(null); when(repository.existsByUsername("platform.user")).thenReturn(false); @@ -67,7 +69,7 @@ class AccountManagementServiceTest { assertNotEquals("Password1", captor.getValue().getPasswordHash()); } - @Test void 相同账号标识和归属重试时幂等返回() { + @Test void createAccountRetriesIdempotentlyForSameAccountAndOwner() { account.setUsername("platform.user"); account.setDisplayName("平台用户"); account.setDefaultTenantId(0L); account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); @@ -79,7 +81,7 @@ class AccountManagementServiceTest { verify(repository, never()).insert(any()); } - @Test void 同步显示名撤销已有会话() { + @Test void synchronizeDisplayNameRevokesExistingSessions() { account.setDisplayName("旧名称"); service.updateProfile(10L, new UpdateAccountProfileRequest("新名称")); diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java index 8eb6ed0ac..0d53e121b 100644 --- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -1,6 +1,7 @@ package com.baiyue.auth.service; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; @@ -16,6 +17,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.eq; +@DisplayName("令牌会话存储") class TokenSessionStoreTest { @Test void storesAccessAndHashedRefreshWithIndependentTtls() { diff --git a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java index 1ba26fbea..b4dbca1a2 100644 --- a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java +++ b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java @@ -3,6 +3,7 @@ package com.baiyue.common.core; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; @@ -10,6 +11,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DisplayName("统一响应对象功能") class ApiResponseTest { @Test void createsSuccessfulResponseWithCorrelationIds() { diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java index 8cf5ac3a3..569608b99 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java @@ -3,6 +3,7 @@ package com.baiyue.common.event; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; @@ -12,11 +13,12 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DisplayName("跨区域事件信封契约") class EventEnvelopeTest { private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @Test - void 标准Uuid事件可以通过校验并完成序列化往返() throws Exception { + void standardUuidEventPassesValidationAndSerializationRoundTrip() throws Exception { EventEnvelope source = validEnvelope(EventIdentifiers.newUuid()); source.requireValid(); @@ -29,14 +31,14 @@ class EventEnvelopeTest { } @Test - void 合法Ulid可以作为事件标识() { + void validUlidCanBeUsedAsEventIdentifier() { EventEnvelope envelope = validEnvelope("01J8Z4QX9K7M2N3P4R5S6T7V8W"); assertThat(EventEnvelopeValidator.validate(envelope)).isEmpty(); } @Test - void 校验器一次返回全部基础错误() { + void validatorReturnsAllBasicErrorsAtOnce() { EventEnvelope envelope = new EventEnvelope( "bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ", -1, 0L, " ", " ", "bad-cause", null, "bad-hash"); @@ -51,7 +53,7 @@ class EventEnvelopeTest { } @Test - void 负载摘要可计算并校验() { + void payloadDigestCanBeCalculatedAndValidated() { byte[] payload = "{\"orderId\":\"90001\"}".getBytes(StandardCharsets.UTF_8); String hash = EventPayloadHash.sha256(payload); @@ -61,7 +63,7 @@ class EventEnvelopeTest { } @Test - void 对象字段顺序不同仍产生相同规范摘要() throws Exception { + void differentObjectFieldOrderProducesSameCanonicalDigest() throws Exception { JsonNode first = objectMapper.readTree("{\"orderId\":\"90001\",\"route\":{\"country\":\"US\",\"city\":\"LA\"}}"); JsonNode second = objectMapper.readTree("{\"route\":{\"city\":\"LA\",\"country\":\"US\"},\"orderId\":\"90001\"}"); @@ -70,7 +72,7 @@ class EventEnvelopeTest { } @Test - void 数组顺序仍作为业务语义保留() throws Exception { + void arrayOrderIsPreservedAsBusinessSemantic() throws Exception { JsonNode first = objectMapper.readTree("{\"steps\":[\"A\",\"B\"]}"); JsonNode second = objectMapper.readTree("{\"steps\":[\"B\",\"A\"]}"); diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index e17a16e06..df55ecf68 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -12,9 +12,11 @@ | 自动配置 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` | | `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | +| 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` | ## 修改与验证 - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 - 配置项变化优先保持旧键兼容,并同步 Nacos 模板。 - 从父目录运行 `mvn -pl common-openapi -am test`。 +- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。 diff --git a/common-openapi/pom.xml b/common-openapi/pom.xml index 8ddfe901a..361af6538 100644 --- a/common-openapi/pom.xml +++ b/common-openapi/pom.xml @@ -10,5 +10,6 @@ org.springdocspringdoc-openapi-starter-webmvc-ui org.springframework.bootspring-boot-starter-webtrue + org.springframework.bootspring-boot-starter-testtest diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java index 04055e3b5..b782e8f27 100644 --- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java @@ -1,5 +1,7 @@ package com.baiyue.common.openapi; +import com.fasterxml.jackson.databind.type.TypeFactory; +import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; @@ -43,6 +45,22 @@ public class BaiyueOpenApiAutoConfiguration { .addParametersItem(header("X-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false)); } + /** 保证 OpenAPI 与运行时 JSON 一致:所有 Java 长整型均声明为字符串。 */ + @Bean + ModelConverter longAsStringModelConverter() { + return (type, context, chain) -> { + if (type.getType() != null) { + Class rawType = TypeFactory.defaultInstance().constructType(type.getType()).getRawClass(); + if (rawType == Long.class || rawType == Long.TYPE) { + return new io.swagger.v3.oas.models.media.StringSchema() + .description("长整型以字符串传输,避免浏览器精度丢失") + .example("190000000000000001"); + } + } + return chain.hasNext() ? chain.next().resolve(type, context, chain) : null; + }; + } + @Bean GroupedOpenApi platformApi(OperationCustomizer tenantHeaderCustomizer) { return GroupedOpenApi.builder().group("platform") diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java new file mode 100644 index 000000000..44a744168 --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java @@ -0,0 +1,29 @@ +package com.baiyue.common.openapi; + +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverter; +import io.swagger.v3.core.converter.ModelConverterContext; +import io.swagger.v3.oas.models.media.Schema; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("长整型标识 OpenAPI 序列化功能") +class LongAsStringModelConverterTest { + + @Test + void longShouldBeDocumentedAsString() { + ModelConverter converter = new BaiyueOpenApiAutoConfiguration().longAsStringModelConverter(); + + Schema schema = converter.resolve( + new AnnotatedType(Long.class), + org.mockito.Mockito.mock(ModelConverterContext.class), + Collections.emptyIterator()); + + assertThat(schema.getType()).isEqualTo("string"); + assertThat(schema.getExample()).isEqualTo("190000000000000001"); + } +} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java index 65967aeac..0f0bd77b9 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -2,6 +2,7 @@ package com.baiyue.common.security; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.mock.web.MockFilterChain; @@ -16,6 +17,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("JWT 认证过滤功能") class JwtAuthenticationFilterTest { @AfterEach void clearContext() { SecurityContextHolder.clearContext(); } diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index dd5000696..0f47397ab 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -1,5 +1,6 @@ package com.baiyue.common.security; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.oauth2.jwt.Jwt; @@ -8,6 +9,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("JWT 声明映射功能") class JwtClaimsMapperTest { @Test void mapsTenantUserRolesAndPermissions() { diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java index 556bf7426..b8f8b9db2 100644 --- a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java @@ -1,5 +1,6 @@ package com.baiyue.common.security; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.data.redis.core.StringRedisTemplate; @@ -12,6 +13,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@DisplayName("访问令牌 Redis 会话校验功能") class RedisAccessTokenSessionValidatorTest { @Test void acceptsActiveSession() { diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 3a60e32d7..c60045455 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -14,6 +14,7 @@ | 全局异常映射 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java` | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | | 异常响应测试 | `src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java` | +| 长整型 JSON 字符串序列化 | `src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java` | ## 修改与验证 @@ -21,3 +22,4 @@ - 查询参数类型转换失败映射为 400,Bean Validation 约束失败映射为 422,禁止落入通用 500。 - 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。 - 从父目录运行 `mvn -pl common-web -am test`。 +- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java new file mode 100644 index 000000000..7cfe445b2 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java @@ -0,0 +1,23 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; + +/** + * 统一将 Java 长整型序列化为 JSON 字符串,避免浏览器对雪花 ID 产生精度截断。 + * + *

请求反序列化仍兼容 JSON 字符串和安全范围内的 JSON 数字;数据库与领域模型继续使用 + * {@code BIGINT}/{@code Long},本配置只改变 HTTP JSON 表达。

+ */ +@AutoConfiguration +public class LongIdJacksonConfiguration { + + @Bean + Jackson2ObjectMapperBuilderCustomizer longAsStringJacksonCustomizer() { + return builder -> builder + .serializerByType(Long.class, ToStringSerializer.instance) + .serializerByType(Long.TYPE, ToStringSerializer.instance); + } +} diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..013f360a4 --- /dev/null +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.baiyue.common.web.LongIdJacksonConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index ee9683390..08bb2e583 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -1,5 +1,6 @@ package com.baiyue.common.web; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; @@ -7,6 +8,7 @@ import org.springframework.mock.web.MockHttpServletResponse; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("请求链路标识过滤功能") class CorrelationIdFilterTest { private final CorrelationIdFilter filter = new CorrelationIdFilter(); diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index f98b3971d..98f7ec0c6 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -2,6 +2,7 @@ package com.baiyue.common.web; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.core.MethodParameter; @@ -11,6 +12,7 @@ import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("全局异常响应") class GlobalExceptionHandlerTest { private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); @@ -26,7 +28,7 @@ class GlobalExceptionHandlerTest { } @Test - void 查询参数类型错误映射为四百而不是五百() throws Exception { + void invalidQueryParameterTypeMapsToBadRequest() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class); MethodParameter parameter = new MethodParameter(method, 0); diff --git a/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java new file mode 100644 index 000000000..d75be7561 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java @@ -0,0 +1,57 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("长整型标识序列化") +class LongIdJacksonConfigurationTest { + + private final ObjectMapper objectMapper = configuredObjectMapper(); + + @Test + void longAndPrimitiveLongShouldBeSerializedAsStrings() throws Exception { + String json = objectMapper.writeValueAsString( + new IdPayload(190000000000000001L, 190000000000000002L)); + + assertThat(json).isEqualTo( + "{\"boxedId\":\"190000000000000001\",\"primitiveId\":\"190000000000000002\"}"); + } + + @Test + void stringLongShouldStillBeDeserialized() throws Exception { + IdPayload payload = objectMapper.readValue( + "{\"boxedId\":\"190000000000000001\",\"primitiveId\":\"190000000000000002\"}", + IdPayload.class); + + assertThat(payload.boxedId()).isEqualTo(190000000000000001L); + assertThat(payload.primitiveId()).isEqualTo(190000000000000002L); + } + + @Test + void autoConfigurationShouldApplyToSpringObjectMapper() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)) + .run(context -> { + ObjectMapper springObjectMapper = context.getBean(ObjectMapper.class); + assertThat(springObjectMapper.writeValueAsString(new IdPayload(1L, 2L))) + .isEqualTo("{\"boxedId\":\"1\",\"primitiveId\":\"2\"}"); + }); + } + + private ObjectMapper configuredObjectMapper() { + Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); + new LongIdJacksonConfiguration().longAsStringJacksonCustomizer().customize(builder); + return builder.build(); + } + + private record IdPayload(Long boxedId, long primitiveId) { + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 2ce1fbb07..fb41b5857 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,6 +22,7 @@ x-common: &common REGION_CODE: ${REGION_CODE:-CN} DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn} DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} + LOG_PATH: /app/logs TZ: Asia/Shanghai logging: driver: json-file @@ -35,6 +36,7 @@ services: image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway ports: ["${GATEWAY_PORT:-9000}:9000"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9000 @@ -47,6 +49,7 @@ services: image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-auth ports: ["${AUTH_PORT:-9100}:9100"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9100 @@ -59,6 +62,7 @@ services: image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-iam ports: ["${IAM_PORT:-9201}:9201"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9201 @@ -71,6 +75,7 @@ services: image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-business ports: ["${FIRSTLEG_PORT:-9202}:9202"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9202 @@ -83,6 +88,7 @@ services: image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-order ports: ["${ORDER_PORT:-9204}:9204"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9204 @@ -95,6 +101,7 @@ services: image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse ports: ["${WAREHOUSE_PORT:-9205}:9205"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9205 @@ -107,7 +114,9 @@ services: image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-file ports: ["${FILE_PORT:-9206}:9206"] - volumes: ["firstleg-file-data:/data/files"] + volumes: + - firstleg-file-data:/data/files + - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs" environment: <<: *common-environment SERVER_PORT: 9206 @@ -121,6 +130,7 @@ services: image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-print ports: ["${PRINT_PORT:-9207}:9207"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9207 @@ -133,6 +143,7 @@ services: image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-integration ports: ["${INTEGRATION_PORT:-9210}:9210"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9210 @@ -152,6 +163,7 @@ services: image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9300 @@ -164,6 +176,7 @@ services: image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-audit expose: ["9700"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/audit-service:/app/logs"] environment: <<: *common-environment SERVER_PORT: 9700 diff --git a/docs/2026-07-31平台管理与头程履约补齐回归报告.md b/docs/2026-07-31平台管理与头程履约补齐回归报告.md new file mode 100644 index 000000000..0337e7c48 --- /dev/null +++ b/docs/2026-07-31平台管理与头程履约补齐回归报告.md @@ -0,0 +1,95 @@ +# 平台管理与头程履约补齐回归报告 + +日期:2026-07-31 +范围:IAM、Warehouse、Order、Firstleg、File 及共享依赖 +边界:只连接本机开发数据库;已执行项目内可重复迁移和可回收联调数据,未连接远程 test 数据库。服务仅使用 `19100/19101/19104/19105/19106` 测试专用端口,回归结束后全部停止,不占用日常 `9000/920x` 端口。 + +## 一、问题核验结论 + +前端报告中的后端缺口基本属实:平台客户档案、平台快递公司、平台往来单位、权限资源维护、平台代客订单写入和履约任务查询此前均不完整。平台权限快照虽然在说明中出现,但 Controller 和 OpenAPI 曾发生漂移,导致登录后权限初始化存在失败风险。 + +本轮已补齐: + +- 当前用户有效权限查询,不依赖角色列表权限,按有效成员、有效角色和有效权限实时计算。 +- 平台客户档案分页、详情、编辑、启停,并阻止通过启停接口绕过企业审核。 +- 平台内部用户的成员主键和角色回显,以及新增、编辑、启停、重置密码、角色分配闭环。 +- 权限资源编辑、启停、删除;系统资源、存在子节点或已绑定角色的资源禁止删除。 +- 平台快递公司和往来单位的分页、详情、新增、编辑、启停,所有读写强制携带目标租户。 +- 平台代客创建、编辑、提交头程订单;商户端和平台端创建、提交均消费 `Idempotency-Key`。 +- 订单不存在统一返回 HTTP 404;Warehouse 下游不可用统一映射为受控 503。 +- Firstleg 履约任务列表、详情、按订单查询、分配历史和分页查询。 +- File 企业资质上传改为流式落盘,服务启动时校验存储根目录;跨平台默认目录不再硬编码 Windows 路径,磁盘故障统一返回 `S0503` 并记录底层异常。 +- `Long/long` 由 `common-web` 统一序列化为 JSON 字符串,请求继续接受字符串 ID;`common-openapi` 同步把长整型声明为字符串,避免 19 位雪花 ID 被浏览器截断。 + +## 二、本地数据库迁移与核验 + +使用各服务 `application-dev.yml` 的本地连接配置,先读取 `information_schema`,再按第四节顺序执行可重复迁移,最后重新核验关键字段和索引。MySQL 版本为 `8.4.11`。 + +| 数据库 | 关键表结果 | 当前数据量 | +|---|---|---| +| `baiyue_iam` | 成员、角色、权限及关联表均存在;`iam_permission.built_in` 已迁移 | 成员 22、角色 15、权限 115、租户 17 | +| `baiyue_warehouse` | 仓库、地址、能力、往来单位、快递公司表及必需字段均存在 | 仓库 1、地址 1、能力 1、往来单位 1、启用快递公司 1 | +| `baiyue_order` | 订单快照字段、快递字段和幂等记录表均已迁移 | 原有订单 5;回归订单已精确清理 | +| `baiyue_firstleg` | 履约任务表和分配历史表均存在 | 两表均为 0 | + +本地 File 存储目录 `D:\baiyue-firstleg-runtime\uploads` 已存在租户 `10001` 的 UUID 资质图片。使用商户 Token 请求 `/api/merchant/v1/files/qualifications/a1d6caa0-14df-4988-831c-d1a8f5d142ff.png` 返回 HTTP 200、`image/png`、47772 字节。旧 `/phase1-seed/demo-business-license.jpg` 记录状态已是 `SUPERSEDED`,不再作为当前有效资质。 + +关键索引只读核验结果:订单幂等唯一索引、Firstleg 租户任务索引、往来单位和快递公司的租户编码唯一索引均已存在。 + +迁移实测发现 MySQL 不支持 `ADD COLUMN IF NOT EXISTS`,IAM 与 Warehouse 增量脚本已改为 `information_schema + 动态 SQL` 的可重复写法,并在本地成功执行。远程测试环境部署时仍必须核对迁移版本;健康检查成功不能证明业务表结构已同步。 + +## 三、自动化回归结果 + +为避免多窗口并行构建清理同一 `target`,先安装共享模块快照,再隔离执行四个业务模块的 `clean test`。 + +| 模块 | 测试数 | 结果 | +|---|---:|---| +| 共享模块 | 16 | 通过 | +| IAM | 58 | 通过 | +| Order | 18 | 通过 | +| Firstleg | 6 | 通过 | +| Warehouse | 28 | 通过 | +| File | 5 | 通过 | +| 全仓合计 | 169 | 全部通过,0 失败、0 错误、0 跳过 | + +业务模块最终命令: + +```powershell +mvn -o "-Dmaven.repo.local=C:\Users\25137\.m2\repository" clean test +``` + +最终结果:19 个 Reactor 模块全部构建成功,169 项测试无失败、无错误、无跳过;`git diff --check` 通过。 + +## 四、真实接口回归 + +本地启动 Auth、IAM、Order、Warehouse、File,统一连接本机数据库和 Redis,并使用测试专用端口完成以下验证: + +- 商户端、平台端统一登录均成功;平台权限快照返回 61 项权限,不依赖角色列表权限。 +- 平台客户分页返回 16 条;内部用户返回 `membershipId` 和 `roleIds`,ID 的 JSON 类型为字符串。 +- 商户仓库、往来单位候选、平台仓库、平台往来单位、平台快递公司均返回真实本地数据;19 位仓库 ID 保持 `190000000000000001`,未发生精度截断。 +- 平台往来单位和快递公司均完成新增、编辑、停用、详情回读;测试记录随后按租户和精确 ID 清理。 +- 商户头程订单完成创建草稿、相同 `Idempotency-Key` 重放返回同一订单 ID、提交、相同提交键重放、详情读取和取消;测试订单及子表、Outbox、幂等记录随后精确清理。 +- 不存在订单返回 HTTP 404;Order OpenAPI 已声明必填 `Idempotency-Key`,`OrderEntity.id` 类型为 `string`,平台提交路径正确。 +- 有效资质文件鉴权预览返回 HTTP 200,文件内容与元数据路径一致。 + +服务启动期间 Nacos 本地配置读取出现一次 `MalformedInputException`,但五个服务均正常启动且健康检查为 `UP`。这是本地 Nacos 配置内容编码问题,不影响本轮源码与数据库结论;应单独把 Nacos YAML 统一保存为 UTF-8。 + +## 五、SQL 脚本位置与执行顺序 + +脚本均放在所属服务的 `src/main/resources/db/`,没有放入临时目录,也没有自动写入本地数据库。 + +1. IAM 已有库:依次执行 `phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`、`phase1-platform-master-data-and-permission-management.sql`。 +2. Warehouse 新库:依次执行 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql`。 +3. Warehouse 已有库:在基础表脚本后执行 `phase1-warehouse-schema-migration.sql`,再按需执行种子脚本。 +4. Order 新库执行 `order.sql`;已有库执行 `phase1-order-extension.sql`。 +5. Firstleg 新库执行 `firstleg.sql`;已有库执行 `phase1-firstleg-task-list-index.sql`。 + +File 本轮不涉及数据库迁移脚本。非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;Docker 部署保持 `/data/files` 卷挂载。 + +以上脚本已在本机开发库按顺序成功执行。其他环境仍需按发布流程独立执行并记录结果,不得以本地执行结果代替环境迁移记录。 + +## 六、仍需环境联调 + +- 部署后的 Gateway 仍需做浏览器级跨租户 403、停用账号、停用权限即时生效及移动端验收。 +- PENDING/REJECTED 企业、普通成员和自定义角色需要在专用测试环境准备可回收样本,完成五状态浏览器验收。 +- 远程 test 环境需按第五节执行迁移并部署同一构建版本;本轮没有连接或写入远程 test 数据库。 diff --git a/docs/phase1-local-api.md b/docs/phase1-local-api.md index 59a8de20d..6d2b4dda9 100644 --- a/docs/phase1-local-api.md +++ b/docs/phase1-local-api.md @@ -289,6 +289,8 @@ IAM 的商户资质接口只读。旧的 `POST /api/merchant/v1/iam/tenant/quali 往来单位由 `warehouse-service` 承载,表为 `baiyue_warehouse.wh_partner`,不是独立服务或独立数据库。仓库和往来单位查询强制执行租户隔离和停用过滤。 +平台往来单位和快递公司均为目标商户租户下的主数据,因此所有平台管理接口都必须显式传入 `tenantId`,缺失或小于 1 返回参数错误。`/api/platform/v1/warehouses/status` 会实际探测仓库、地址、服务能力、往来单位和快递公司表的必需字段;缺表或缺列返回 HTTP 503 且状态为 `DEGRADED`。业务查询遇到数据源不可用或结构漂移时统一返回 `S0503`,不再用 `S0500` 隐藏迁移问题。 + 部署到既有数据库时,除主建表脚本外还要执行 `firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql`;该脚本可重复执行,用于补齐履约任务租户时间分页索引。 平台履约可通过 `PUT /api/platform/v1/orders/first-leg/{orderId}/express?tenantId={tenantId}` 写入 `expressCompany,expressTrackingNo`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。 diff --git a/docs/企业资质文件迁移说明.md b/docs/企业资质文件迁移说明.md index bd4e6e3bc..f481b01aa 100644 --- a/docs/企业资质文件迁移说明.md +++ b/docs/企业资质文件迁移说明.md @@ -43,6 +43,19 @@ WHERE deleted = 0 5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。 6. 再次通过平台预览接口验收;验收完成前保留旧文件和旧 URL 清单,以便回滚。 +当前 File 服务使用本地或共享文件目录时,可先运行仓库内脚本生成真实文件、前向 SQL 和回滚 SQL: + +```powershell +.\scripts\migrate-local-qualification-file.ps1 ` + -ImagePath D:\test-data\脱敏营业执照.png ` + -StorageRoot D:\baiyue-firstleg-runtime\uploads ` + -TenantId 10001 ` + -QualificationId 资质ID ` + -OldDocumentUrl /phase1-seed/demo-business-license.jpg +``` + +脚本会校验文件大小、JPG/PNG 文件签名和复制前后 SHA-256,并生成带 `id + tenant_id + 旧 URL` 乐观条件的前向 SQL及回滚 SQL。脚本不会连接数据库;执行人员确认文件已落到 File 服务实际挂载目录后,才可人工执行生成的前向 SQL。若受影响行数不是 1,必须回滚并删除本次新增文件。对象存储环境不使用该脚本,应通过 File 上传接口迁移。 + 数据库更新模板如下,执行时必须替换为经过文件校验的真实值: ```sql @@ -60,3 +73,5 @@ COMMIT; ## 新环境初始化 初始化脚本不再创建 `/phase1-seed/...` 虚假资质。测试环境需要可下单的商户时,应使用脱敏但真实存在的 JPG/PNG 文件走 File 上传、平台审核完整流程,使租户达到 `ACTIVE` 且资质达到 `APPROVED`。初始化账号、文件和审核结果应作为一套环境数据验收,不得仅靠 SQL 绕过文件保存。 + +用于联调的样本必须在名称或图片水印中明确标注“仅供测试”,并登记租户 ID、资质 ID、文件 SHA-256 和创建时间。验收结束后按生成的回滚 SQL恢复原元数据并删除本次文件;若样本通过正常上传审核流程产生,应由测试数据清理流程按同一组标识回收,禁止直接删除仍被资质元数据引用的文件。 diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java index 1b8dfb9d2..9ad3b1ac5 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java @@ -1,20 +1,22 @@ package com.baiyue.eventbridge.security; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.web.server.ResponseStatusException; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DisplayName("内部服务令牌校验") class InternalServiceTokenGuardTest { @Test - void 正确令牌允许访问() { + void validTokenAllowsAccess() { InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token"); assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException(); } @Test - void 未配置令牌时默认关闭接收接口() { + void unconfiguredTokenDisablesReceiveEndpoint() { InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); assertThatThrownBy(() -> guard.verify("anything")) .isInstanceOf(ResponseStatusException.class) @@ -22,7 +24,7 @@ class InternalServiceTokenGuardTest { } @Test - void 错误令牌被拒绝() { + void invalidTokenIsRejected() { InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected"); assertThatThrownBy(() -> guard.verify("wrong")) .isInstanceOf(ResponseStatusException.class) diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 97fc88360..f2d21e484 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -6,6 +6,7 @@ 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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; @@ -20,13 +21,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@DisplayName("跨区域事件收件箱") class EventInboxServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final InboxStore store = mock(InboxStore.class); private final EventInboxService service = new EventInboxService(store, objectMapper, "OVERSEAS"); @Test - void 首次事件落库并返回已接收() { + void firstEventPersistsAndReturnsReceived() { EventEnvelope envelope = envelope("sha256:placeholder"); envelope = withHash(envelope); @@ -35,7 +37,7 @@ class EventInboxServiceTest { } @Test - void 相同事件和摘要重复接收不再次写入() { + void identicalEventAndDigestDoesNotPersistAgain() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); BridgeInbox existing = existing(envelope.payloadHash()); when(store.findByEventId(envelope.eventId())).thenReturn(existing); @@ -44,7 +46,7 @@ class EventInboxServiceTest { } @Test - void 相同事件但摘要不同时拒绝覆盖() { + void sameEventWithDifferentDigestRejectsOverwrite() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64))); @@ -54,7 +56,7 @@ class EventInboxServiceTest { } @Test - void 并发插入冲突后按既有记录判定为重复() { + void duplicateInsertAfterRaceIsTreatedAsDuplicate() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); @@ -63,7 +65,7 @@ class EventInboxServiceTest { } @Test - void 目标区域与当前部署区域不一致时拒绝接收() { + void targetRegionDifferentFromCurrentDeploymentIsRejected() { EventInboxService cnService = new EventInboxService(store, objectMapper, "CN"); EventEnvelope envelope = withHash(envelope("sha256:placeholder")); diff --git a/file/AGENTS.md b/file/AGENTS.md index 27d1fcaf1..eb87487db 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -19,6 +19,7 @@ | 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` | | IAM 权限协作 | `client/IamQualificationClient.java` | | 目标表结构(尚未接入) | `src/main/resources/db/file.sql` | +| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` | ## 修改与验证 @@ -27,3 +28,6 @@ - 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。 - 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。 - 当前没有专属自动化测试;从父目录运行 `mvn -pl file -am test`,并按接口影响验证上传、预览、无权限和非法文件场景。 +- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而上传返回 500。 +- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。 +- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。 diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index 830394a15..91f37a3e6 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -1,6 +1,10 @@ package com.baiyue.file.service; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -11,12 +15,15 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.StandardOpenOption; import java.util.Locale; import java.util.Set; import java.util.UUID; @Service public class LocalQualificationStorage { + private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class); private static final long MAX_SIZE = 5L * 1024 * 1024; private static final Set TYPES = Set.of("image/jpeg", "image/png"); private final Path root; @@ -25,6 +32,20 @@ public class LocalQualificationStorage { this.root = Path.of(root).toAbsolutePath().normalize(); } + /** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */ + @PostConstruct + public void initialize() { + try { + Files.createDirectories(root); + if (!Files.isDirectory(root) || !Files.isWritable(root)) { + throw new IOException("存储根目录不可写"); + } + } catch (IOException exception) { + LOGGER.error("企业资质存储根目录不可用:{}", root, exception); + throw new IllegalStateException("企业资质存储根目录不可用", exception); + } + } + public StoredFile store(long tenantId, MultipartFile file) { if (file == null || file.isEmpty()) throw new BizException("One business-license image is required"); if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB"); @@ -40,11 +61,19 @@ public class LocalQualificationStorage { if (!target.startsWith(directory)) throw new BizException("Invalid upload path"); try { Files.createDirectories(directory); - file.transferTo(target); + try (InputStream input = file.getInputStream(); + OutputStream output = Files.newOutputStream(target, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + input.transferTo(output); + } return new StoredFile(target, fileName, "/api/merchant/v1/files/qualifications/" + fileName); } catch (IOException exception) { - throw new BizException("Unable to store enterprise qualification"); + try { Files.deleteIfExists(target); } catch (IOException cleanupException) { + exception.addSuppressed(cleanupException); + } + LOGGER.error("企业资质文件写入失败,tenantId={},目录={}", tenantId, directory, exception); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业资质文件存储暂不可用"); } } diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 47aaa70f1..a024e7c2c 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -32,5 +32,5 @@ baiyue: iam: url: ${IAM_SERVICE_URL:} file: - storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} + storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 0ce2d6a75..3cb468f2d 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -1,5 +1,6 @@ package com.baiyue.file.controller; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; @@ -9,6 +10,7 @@ import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertTrue; +@DisplayName("企业资质文件接口契约功能") class QualificationFileContractTest { @Test diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 292f503c7..552c7793e 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -1,6 +1,8 @@ package com.baiyue.file.service; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +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; @@ -12,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +@DisplayName("本地企业资质文件存储功能") class LocalQualificationStorageTest { @TempDir @@ -20,6 +23,7 @@ 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); @@ -39,4 +43,29 @@ class LocalQualificationStorageTest { assertThrows(BizException.class, () -> storage.load(10001L, "demo-business-license.jpg")); } + + @Test + void invalidStorageFailsFastDuringStartup() throws Exception { + Path invalidRoot = storageRoot.resolve("root-is-file"); + Files.writeString(invalidRoot, "not-a-directory"); + var storage = new LocalQualificationStorage(invalidRoot.toString()); + + IllegalStateException exception = assertThrows(IllegalStateException.class, storage::initialize); + + assertTrue(exception.getMessage().contains("存储根目录不可用")); + } + + @Test + void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception { + var storage = new LocalQualificationStorage(storageRoot.toString()); + storage.initialize(); + Files.writeString(storageRoot.resolve("qualifications"), "阻止创建租户目录"); + 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)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); + } } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index d6dbeb105..0dd49e293 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,3 +28,4 @@ - 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。 - 当前模块没有专属自动化测试时,不要为小改动擅自引入新框架;至少运行模块构建并记录接口验证。 - 从父目录运行 `mvn -pl firstleg -am test`。 +- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。 diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 344b9aca3..467108e07 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml firstleg-service + com.baiyue.sharedbaiyue-common-core${project.version} com.baiyue.sharedbaiyue-common-audit${project.version} com.baiyue.sharedbaiyue-common-openapi${project.version} com.baomidoumybatis-plus-spring-boot3-starter diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java index 32cd2f2cb..66645eefb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.AssignRequest; import com.baiyue.firstleg.dto.FulfillmentTaskQuery; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.core.io.ClassPathResource; @@ -12,9 +13,11 @@ import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("履约任务控制器契约") class FulfillmentTaskControllerContractTest { @Test - void 查询和分配接口声明对应权限码() throws Exception { + @DisplayName("查询和分配接口声明对应权限码") + void shouldDeclarePermissionsForQueryAndAssignmentEndpoints() throws Exception { assertAuthority("page", "hasAuthority('platform:firstleg:task:list')", AuthenticatedUser.class, long.class, FulfillmentTaskQuery.class); assertAuthority("detail", "hasAuthority('platform:firstleg:task:view')", @@ -28,14 +31,16 @@ class FulfillmentTaskControllerContractTest { } @Test - void 分页查询缺省值为第一页每页二十条() { + @DisplayName("分页查询缺省值为第一页每页二十条") + void shouldDefaultPageQueryToFirstPageAndTwentyItems() { FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 0, 0); assertThat(query.page()).isEqualTo(1); assertThat(query.size()).isEqualTo(20); } @Test - void OpenAPI覆盖四类只读接口和对应权限() throws Exception { + @DisplayName("OpenAPI覆盖四类只读接口和对应权限") + void shouldDocumentFourReadOnlyEndpointsInOpenApi() throws Exception { String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java index ce793db13..3a77a1533 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java @@ -6,6 +6,7 @@ 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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -17,9 +18,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@DisplayName("履约任务服务") class FulfillmentTaskServiceTest { @Test - void 列表查询保留租户和分页条件() { + @DisplayName("列表查询保留租户和分页条件") + void shouldPreserveTenantAndPaginationInPageQuery() { FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 1, 20); PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); @@ -30,7 +33,8 @@ class FulfillmentTaskServiceTest { } @Test - void 详情和按订单查询不存在时返回404() { + @DisplayName("详情和按订单查询不存在时返回404") + void shouldReturnNotFoundForMissingTaskAndOrder() { FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); FulfillmentTaskService service = service(repository); @@ -45,7 +49,8 @@ class FulfillmentTaskServiceTest { } @Test - void 查询分配历史前先校验任务归属租户() { + @DisplayName("查询分配历史前先校验任务归属租户") + void shouldVerifyTaskTenantBeforeLoadingAssignmentHistory() { FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class); FulfillmentTaskEntity task = new FulfillmentTaskEntity(); when(repository.findOwned(10001L, 1L)).thenReturn(task); diff --git a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java index 21e2371a7..f7b102276 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java @@ -1,5 +1,6 @@ package com.baiyue.gateway.config; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; @@ -9,6 +10,7 @@ import reactor.test.StepVerifier; import java.time.Instant; +@DisplayName("网关应用域授权功能") class AppScopeAuthorizationManagerTest { @Test void acceptsMatchingApplicationScope() { diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java index a4631a245..142e22997 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java @@ -1,5 +1,6 @@ package com.baiyue.gateway.config; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; @@ -17,6 +18,7 @@ import org.springframework.test.web.reactive.server.WebTestClient; "baiyue.cors.allowed-origins=http://localhost:5173" }) @AutoConfigureWebTestClient +@DisplayName("网关跨域配置功能") class GatewayCorsConfigurationTest { private static final String PROFILE_URL = "http://localhost/api/merchant/v1/iam/tenant/profile"; diff --git a/iam/AGENTS.md b/iam/AGENTS.md index c78c4af09..2e9495867 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -27,6 +27,8 @@ | 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料和 RBAC 测试 | ## 修改与验证 +- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。 +- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。 - `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 @@ -34,5 +36,7 @@ - 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。 - 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。 - 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。 -- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。 +- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。 +- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。 +- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。 - 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。 diff --git a/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java index 7fe156f7d..6439cffd3 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java @@ -25,18 +25,17 @@ import java.util.Set; * 因此角色被停用或权限被回收后,下一次查询会立即生效。

*/ @RestController -@Tag(name = "Authorization / 当前用户授权", description = "Returns current member effective permission codes.") +@Tag(name = "当前用户授权", description = "返回当前成员的有效权限编码。") public class AuthorizationController { @Resource private AuthorizationSnapshotService authorizationSnapshotService; @GetMapping("/api/{app}/v1/iam/authorization/permissions") - @Operation(summary = "Get current effective permissions / 查询当前用户有效权限", - description = "Does not require role-management permissions. / 不依赖角色管理权限。") + @Operation(summary = "查询当前用户有效权限", description = "不依赖角色管理权限。") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Permissions returned"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Wrong application scope or inactive identity") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "权限查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "应用权限域错误或身份已停用") }) public ApiResponse> permissions(@PathVariable String app, @AuthenticationPrincipal AuthenticatedUser user) { @@ -49,7 +48,7 @@ public class AuthorizationController { return switch (app) { case "platform" -> "PLATFORM"; case "merchant" -> "MERCHANT"; - default -> throw new BizException(ResultCode.BAD_REQUEST, "Unsupported application scope / 不支持的应用端"); + default -> throw new BizException(ResultCode.BAD_REQUEST, "不支持的应用端"); }; } } diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 0839545b9..1ad414d52 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -6,6 +6,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.ReviewRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; @@ -38,7 +40,7 @@ public class PlatformTenantController { @GetMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:list')") - @Operation(summary = "List enterprise tenants / 查询企业列表", description = "Returns paged merchant enterprise tenants for platform operators. / 平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") + @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), @@ -57,6 +59,15 @@ public class PlatformTenantController { return ApiResponse.success(service.pageMerchants(page, size, keyword, status)); } + @GetMapping("/tenants/{tenantId:\\d+}") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询企业客户档案") + public ApiResponse tenant(@PathVariable long tenantId, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.merchant(tenantId)); + } + @PostMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:create')") @Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。") @@ -72,7 +83,28 @@ public class PlatformTenantController { return ApiResponse.success(service.register(request)); } + @PutMapping("/tenants/{tenantId:\\d+}") + @PreAuthorize("hasAuthority('platform:customer:edit')") + @Operation(summary = "编辑企业客户档案") + public ApiResponse updateTenant(@PathVariable long tenantId, + @Valid @RequestBody UpdateTenantRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.updateMerchant(tenantId, request)); + } + + @PutMapping("/tenants/{tenantId:\\d+}/status") + @PreAuthorize("hasAuthority('platform:customer:status')") + @Operation(summary = "启停企业客户") + public ApiResponse changeTenantStatus(@PathVariable long tenantId, + @Valid @RequestBody ChangeTenantStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.changeMerchantStatus(tenantId, request)); + } + @GetMapping("/reviews") + @PreAuthorize("hasAuthority('platform:customer:review')") @Operation(summary = "List pending enterprise reviews / 查询待审核企业", description = "Return pending enterprise registration review tickets ordered by creation. / 按创建顺序返回待审核企业注册单。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"), diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java index bf8fc3be0..d4d8f77c3 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java @@ -81,6 +81,16 @@ public class ProfileResourceController { return ApiResponse.success(null); } + @PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles") + @PreAuthorize("hasAuthority('platform:user:grant-role')") + @Operation(summary = "分配平台内部用户角色") + public ApiResponse assignInternalUserRoles( + @PathVariable Long membershipId, @Valid @RequestBody AssignInternalUserRolesRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(internalUserService.assignRoles(membershipId, request)); + } + @GetMapping("/api/merchant/v1/iam/tenant/addresses") @Operation(summary = "List enterprise addresses / 查询企业地址", description = "Tenant ID is taken from the merchant JWT. / 租户 ID 从商户 JWT 获取。") @ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address list returned / 查询成功")}) diff --git a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java index 29b315105..c2056cee6 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java @@ -122,6 +122,36 @@ public class RbacController { return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request)); } + @PutMapping("/permissions/{permissionId}") + @Operation(summary = "编辑自定义权限资源") + @PreAuthorize("hasAuthority(#app + ':permission:edit')") + public ApiResponse updatePermission(@PathVariable String app, @PathVariable Long permissionId, + @Valid @RequestBody UpdatePermissionRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request)); + } + + @PutMapping("/permissions/{permissionId}/status") + @Operation(summary = "启停自定义权限资源") + @PreAuthorize("hasAuthority(#app + ':permission:status')") + public ApiResponse changePermissionStatus(@PathVariable String app, @PathVariable Long permissionId, + @Valid @RequestBody ChangePermissionStatusRequest request, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + return ApiResponse.success(service.changePermissionStatus(scope.tenantId(), scope.appCode(), permissionId, request)); + } + + @DeleteMapping("/permissions/{permissionId}") + @Operation(summary = "删除未绑定的自定义权限资源") + @PreAuthorize("hasAuthority(#app + ':permission:delete')") + public ApiResponse deletePermission(@PathVariable String app, @PathVariable Long permissionId, + @AuthenticationPrincipal AuthenticatedUser user) { + Scope scope = scope(app, user); + service.deletePermission(scope.tenantId(), scope.appCode(), permissionId); + return ApiResponse.success(null); + } + @PutMapping("/roles/permissions") @Operation(summary = "全量设置角色权限,空集合表示清空") @PreAuthorize("hasAuthority(#app + ':role:grant')") diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java index 10becd5e9..5e36682fa 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java @@ -44,6 +44,9 @@ public final class PlatformInternalUserDtos { @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } + /** 全量替换平台成员角色;空集合表示清空全部自定义角色。 */ + public record AssignInternalUserRolesRequest(@NotNull List roleIds) { } + public record InternalUserResponse( Long membershipId, Long userId, diff --git a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java index a10dafd27..c6719b793 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java @@ -38,6 +38,17 @@ public final class RbacDtos { String httpMethod, Integer sortNo) { } + /** 权限编码和类型参与服务端鉴权,编辑时不可变更。 */ + public record UpdatePermissionRequest( + @NotBlank String name, + Long parentId, + String routePath, + String httpMethod, + Integer sortNo) { } + + public record ChangePermissionStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + public record AssignRolesRequest(@NotNull Long membershipId, @NotNull Set roleIds) { } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index f424ac26a..fa7ef44a8 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -30,6 +30,21 @@ public final class TenantDtos { @Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail ) { } + /** 平台端客户档案维护。统一社会信用代码是注册标识,不允许经由普通编辑接口修改。 */ + @Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile") + public record UpdateTenantRequest( + @NotBlank String tenantName, + @NotBlank String legalName, + @NotBlank String contactName, + @NotBlank String contactPhone, + @Email String contactEmail + ) { } + + @Schema(name = "TenantStatusChangeRequest", description = "Platform controlled merchant tenant status") + public record ChangeTenantStatusRequest( + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status + ) { } + @Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见") public record ReviewRequest( @Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java index a52de5219..13a4f7571 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.Setter; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; /** @@ -25,6 +26,10 @@ public class IamPermission { private String httpMethod; private Integer sortNo; private String status; + /** 系统预置资源不得通过租户权限管理接口修改。 */ + private Boolean builtIn; + @TableLogic + private Integer deleted; diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index 155d08a17..3222ba497 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -46,6 +46,30 @@ public class RbacRepository { public void insertRole(IamRole role) { roleMapper.insert(role); } public void updateRole(IamRole role) { roleMapper.updateById(role); } public void insertPermission(IamPermission permission) { permissionMapper.insert(permission); } + public void updatePermission(IamPermission permission) { permissionMapper.updateById(permission); } + + /** 写操作必须精确匹配所属权限域;全局模板可读,但商户不可修改。 */ + public IamPermission findOwnedPermission(Long id, long tenantId, String appCode) { + return permissionMapper.selectOne(new LambdaQueryWrapper() + .eq(IamPermission::getId, id) + .eq(IamPermission::getTenantId, tenantId) + .eq(IamPermission::getAppCode, appCode)); + } + + public boolean hasPermissionChildren(long tenantId, String appCode, Long permissionId) { + return permissionMapper.selectCount(new LambdaQueryWrapper() + .eq(IamPermission::getTenantId, tenantId) + .eq(IamPermission::getAppCode, appCode) + .eq(IamPermission::getParentId, permissionId)) > 0; + } + + public boolean isPermissionGranted(long tenantId, Long permissionId) { + return rolePermissionMapper.selectCount(new LambdaQueryWrapper() + .eq(IamRolePermission::getTenantId, tenantId) + .eq(IamRolePermission::getPermissionId, permissionId)) > 0; + } + + public void deletePermission(IamPermission permission) { permissionMapper.deleteById(permission.getId()); } public List findRoles(long tenantId, String appCode) { return roleMapper.selectList(new LambdaQueryWrapper() diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 3185e6085..4ecc46595 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -155,6 +155,19 @@ public class PlatformInternalUserService { new AuthAccountClient.PasswordRequest(request.newPassword()))); } + @Transactional + @BusinessAudit(event = "PLATFORM_INTERNAL_USER_ROLE_ASSIGN", resourceType = "MEMBERSHIP", + resourceId = "#membershipId", description = "分配平台内部用户角色", captureAfter = false) + public InternalUserResponse assignRoles(Long membershipId, AssignInternalUserRolesRequest request) { + IamMembership membership = required(membershipId); + List roleIds = normalizeRoles(request.roleIds()); + validateRoles(roleIds); + repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + IamUser user = requiredUser(membership.getUserId()); + AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); + return response(membership, user, authAccount.username(), roleIds); + } + private InternalUserResponse linkExistingAccount(CreateInternalUserRequest request, List roleIds) { IamUser existingUser = repository.findUserByAuthId(request.authUserId()); IamMembership existingMembership = repository.findByTenantAndAuthUser( diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index e991e1aeb..65c8c8d8b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -131,6 +131,7 @@ public class RbacService { permission.setHttpMethod(request.httpMethod()); permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo()); permission.setStatus("ACTIVE"); + permission.setBuiltIn(false); try { repository.insertPermission(permission); } catch (DataIntegrityViolationException exception) { @@ -139,6 +140,46 @@ public class RbacService { return permission; } + @BusinessAudit(event = "PERMISSION_UPDATE", resourceType = "PERMISSION", resourceId = "#permissionId", + description = "编辑权限资源", captureBefore = true, captureAfter = true) + public IamPermission updatePermission(long tenantId, String appCode, Long permissionId, + UpdatePermissionRequest request) { + IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId); + validatePermissionParent(tenantId, appCode, permissionId, request.parentId()); + permission.setPermissionName(request.name()); + permission.setParentId(request.parentId() == null ? 0L : request.parentId()); + permission.setRoutePath(request.routePath()); + permission.setHttpMethod(request.httpMethod()); + permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo()); + repository.updatePermission(permission); + return permission; + } + + @BusinessAudit(event = "PERMISSION_STATUS_CHANGE", resourceType = "PERMISSION", resourceId = "#permissionId", + description = "启停权限资源", captureBefore = true, captureAfter = true) + public IamPermission changePermissionStatus(long tenantId, String appCode, Long permissionId, + ChangePermissionStatusRequest request) { + IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId); + permission.setStatus(request.status()); + repository.updatePermission(permission); + return permission; + } + + /** 带有子资源或已授权给角色的资源不得直接删除。 */ + @BusinessAudit(event = "PERMISSION_DELETE", resourceType = "PERMISSION", resourceId = "#permissionId", + description = "删除权限资源", captureBefore = true, captureAfter = false) + @Transactional + public void deletePermission(long tenantId, String appCode, Long permissionId) { + IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId); + if (repository.hasPermissionChildren(tenantId, appCode, permissionId)) { + throw new BizException(ResultCode.CONFLICT, "Permission has child resources / 权限资源存在子节点,不能删除"); + } + if (repository.isPermissionGranted(tenantId, permissionId)) { + throw new BizException(ResultCode.CONFLICT, "Permission is granted to roles / 权限资源已被角色授予,不能删除"); + } + repository.deletePermission(permission); + } + public MemberRolesResponse memberRoles(long tenantId, String appCode, Long membershipId) { requireMembership(tenantId, membershipId); Set scopedIds = new LinkedHashSet<>(); @@ -191,6 +232,34 @@ public class RbacService { return role; } + private IamPermission requireMutablePermission(long tenantId, String appCode, Long permissionId) { + IamPermission permission = repository.findOwnedPermission(permissionId, tenantId, appCode); + if (permission == null) throw forbidden(); + if (Boolean.TRUE.equals(permission.getBuiltIn())) { + throw new BizException(ResultCode.CONFLICT, + "Built-in permission cannot be changed / 系统预置权限不允许编辑、启停或删除"); + } + return permission; + } + + private void validatePermissionParent(long tenantId, String appCode, Long permissionId, Long parentId) { + if (parentId == null || parentId == 0) return; + if (parentId.equals(permissionId)) { + throw new BizException(ResultCode.BAD_REQUEST, "Permission cannot be its own parent / 权限不能设置自身为父节点"); + } + IamPermission parent = repository.findPermission(parentId, tenantId, appCode); + if (parent == null) throw forbidden(); + Long cursor = parent.getParentId(); + while (cursor != null && cursor > 0) { + if (cursor.equals(permissionId)) { + throw new BizException(ResultCode.BAD_REQUEST, "Permission parent cycle is not allowed / 权限节点不能形成环"); + } + IamPermission ancestor = repository.findPermission(cursor, tenantId, appCode); + if (ancestor == null || cursor.equals(ancestor.getParentId())) break; + cursor = ancestor.getParentId(); + } + } + private IamRole requireCustomRole(long tenantId, String appCode, Long roleId) { IamRole role = requireRole(tenantId, appCode, roleId); if ("SYSTEM".equals(role.getRoleType())) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index ebf5739fc..611ab1687 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -14,6 +14,8 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; +import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Service; @@ -145,6 +147,39 @@ public class TenantService { return response(tenant); } + /** 平台端查询客户档案:仅能查商户租户,不暴露平台租户。 */ + public TenantResponse merchant(long tenantId) { + return response(requireMerchant(tenantId)); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", + description = "平台编辑企业客户档案") + public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) { + IamTenant tenant = requireMerchant(tenantId); + tenant.setTenantName(request.tenantName()); + tenant.setLegalName(request.legalName()); + tenant.setContactName(request.contactName()); + tenant.setContactPhone(request.contactPhone()); + tenant.setContactEmail(request.contactEmail()); + repository.saveTenant(tenant); + return response(tenant); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_STATUS_CHANGE", resourceType = "TENANT", resourceId = "#tenantId", + description = "平台启停企业客户") + public TenantResponse changeMerchantStatus(long tenantId, ChangeTenantStatusRequest request) { + IamTenant tenant = requireMerchant(tenantId); + // PENDING/REJECTED 必须走审核流程,不允许状态维护接口绕过审核。 + if (!"ACTIVE".equals(tenant.getStatus()) && !"DISABLED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Tenant status must be changed through review / 当前租户状态必须通过审核流程变更"); + } + tenant.setStatus(request.status()); + repository.saveTenant(tenant); + return response(tenant); + } + @Transactional @BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", description = "编辑企业资料") @@ -166,6 +201,14 @@ public class TenantService { return tenant.getId() != null && tenant.getId().longValue() == id; } + private IamTenant requireMerchant(long tenantId) { + IamTenant tenant = repository.findTenant(tenantId); + if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在"); + } + return tenant; + } + private TenantResponse response(IamTenant tenant) { return new TenantResponse( tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 21a89a701..d132c8573 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -158,6 +158,7 @@ CREATE TABLE IF NOT EXISTS iam_permission ( http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法', sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态', + built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置权限不可通过管理接口修改', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 19fc30c2a..8f832adb1 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -121,6 +121,26 @@ ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), route_path = VALUES(route_path), http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; +-- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。 +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0), + (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0), + (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 FROM iam_permission +WHERE id IN (5102019, 5102024, 5102025) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000003, id, 0 FROM iam_permission +WHERE id IN (5102019, 5102024, 5102025) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + -- Merchant: level-one menus, level-two menus and buttons. INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql new file mode 100644 index 000000000..2a76408f6 --- /dev/null +++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql @@ -0,0 +1,52 @@ +-- 权限资源维护与平台主数据权限迁移。 +-- 在 phase1-admin-rbac.sql、rbac-role-management-v2.sql 之后执行,可重复执行。 +USE baiyue_iam; + +-- 兼容不支持 ADD COLUMN IF NOT EXISTS 的 MySQL 版本,通过元数据判断后执行。 +SET @iam_permission_built_in_sql = IF( + EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'iam_permission' AND column_name = 'built_in'), + 'SELECT 1', + "ALTER TABLE iam_permission ADD COLUMN built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置资源不可通过管理接口修改' AFTER status" +); +PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql; +EXECUTE iam_permission_built_in_stmt; +DEALLOCATE PREPARE iam_permission_built_in_stmt; + +-- 一期固定编号范围内的资源均为种子资源,运行期生成的雪花编号不作预置标记。 +UPDATE iam_permission +SET built_in = 1 +WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + sort_no, status, built_in, deleted) +VALUES + (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0), + (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0), + (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0), + (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0), + (5101034, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:delete', '删除权限资源', 40, 'ACTIVE', 1, 0), + (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', 10, 'ACTIVE', 1, 0), + (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', 20, 'ACTIVE', 1, 0), + (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', 30, 'ACTIVE', 1, 0), + (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0), + (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0), + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE', + built_in = 1, deleted = 0; + +-- 新增平台操作权限默认只授予系统管理员。 +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT 4000001, id, 0 +FROM iam_permission +WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role', + 'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete', + 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', + 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status' + ) +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql index 125ec6108..dbdacdd99 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -15,6 +15,7 @@ VALUES (5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', 20, 'ACTIVE', 0), (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), + (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), @@ -32,7 +33,8 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete', 'platform:member:list', 'platform:member:grant-role', 'platform:customer:create', - 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password') + 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password', + 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 5147f5d0c..d12ba99f6 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -20,8 +20,8 @@ paths: /api/platform/v1/iam/tenants: get: tags: [Platform Tenant / 平台租户] - summary: List enterprise tenants / 查询企业客户列表 - description: Returns paged merchant tenants. Requires `platform:customer:list`. / 分页查询商户企业客户,需要 `platform:customer:list` 权限。 + summary: 查询企业客户列表 + description: 分页查询商户企业客户,需要 `platform:customer:list` 权限。 parameters: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} @@ -54,8 +54,8 @@ paths: /api/platform/v1/iam/authorization/permissions: get: tags: [IAM Resources / IAM 资源] - summary: Get current effective permissions / 查询当前用户有效权限 - description: Returns the current active platform membership's permission codes. It only requires a valid PLATFORM JWT and does not require `platform:role:list`. / 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。 + summary: 查询当前用户有效权限 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。 responses: '200': description: 当前平台成员权限查询成功 @@ -64,11 +64,51 @@ paths: schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'} '401': {$ref: '#/components/responses/Unauthorized'} '403': {$ref: '#/components/responses/Forbidden'} + /api/platform/v1/iam/tenants/{tenantId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询企业客户档案 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [Platform Tenant / 平台租户] + summary: 编辑企业客户档案 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdateTenantRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/status: + parameters: + - {$ref: '#/components/parameters/TenantId'} + put: + tags: [Platform Tenant / 平台租户] + summary: 启停企业客户 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangeTenantStatusRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/merchant/v1/iam/authorization/permissions: get: tags: [IAM Resources / IAM 资源] - summary: Get current effective permissions / 查询当前用户有效权限 - description: Returns the current active merchant membership's permission codes. / 返回当前有效商户成员权限编码。 + summary: 查询当前用户有效权限 + description: 返回当前有效商户成员权限编码。 responses: '200': description: 当前商户成员权限查询成功 @@ -193,6 +233,27 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/internal-users/{membershipId}/roles: + parameters: + - {$ref: '#/components/parameters/MembershipId'} + put: + tags: [IAM 资源] + summary: 全量分配平台内部用户角色 + description: 仅允许分配平台租户下已启用的平台端角色;空集合表示清空角色。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/AssignInternalUserRolesRequest'} + responses: + '200': + description: 角色分配成功并返回最新内部用户模型 + content: + application/json: + schema: {$ref: '#/components/schemas/InternalUserApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} /api/merchant/v1/iam/tenant/profile: get: tags: [Merchant Tenant / 商户企业] @@ -341,6 +402,49 @@ paths: '200': {$ref: '#/components/responses/Success'} '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} + /api/{app}/v1/iam/permissions/{permissionId}: + parameters: + - {$ref: '#/components/parameters/App'} + - {$ref: '#/components/parameters/PermissionId'} + put: + tags: [RBAC] + summary: 编辑自定义权限资源 + description: 权限编码和类型不可变更,系统预置资源不可修改。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UpdatePermissionRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + delete: + tags: [RBAC] + summary: 删除未绑定的自定义权限资源 + description: 系统预置资源、存在子资源或已绑定角色的资源不可删除。 + responses: + '200': {$ref: '#/components/responses/Success'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/{app}/v1/iam/permissions/{permissionId}/status: + parameters: + - {$ref: '#/components/parameters/App'} + - {$ref: '#/components/parameters/PermissionId'} + put: + tags: [RBAC] + summary: 启停自定义权限资源 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ChangePermissionStatusRequest'} + responses: + '200': {$ref: '#/components/responses/Success'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} components: securitySchemes: bearerAuth: @@ -366,7 +470,19 @@ components: name: membershipId in: path required: true - description: Platform membership ID / 平台成员关系 ID + description: 平台成员关系 ID + schema: {type: integer, format: int64} + TenantId: + name: tenantId + in: path + required: true + description: 企业客户租户 ID + schema: {type: integer, format: int64, minimum: 1} + PermissionId: + name: permissionId + in: path + required: true + description: 权限资源 ID schema: {type: integer, format: int64} responses: Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} @@ -416,6 +532,12 @@ components: - type: object properties: data: {type: array, items: {$ref: '#/components/schemas/InternalUserResponse'}} + InternalUserApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/InternalUserResponse'} TenantResponse: type: object required: [id, tenantCode, tenantName, status] @@ -448,6 +570,20 @@ components: contactName: {type: string} contactPhone: {type: string} contactEmail: {type: string, format: email} + UpdateTenantRequest: + type: object + required: [tenantName, legalName, contactName, contactPhone] + properties: + tenantName: {type: string} + legalName: {type: string} + contactName: {type: string} + contactPhone: {type: string} + contactEmail: {type: string, format: email} + ChangeTenantStatusRequest: + type: object + required: [status] + properties: + status: {type: string, enum: [ACTIVE, DISABLED]} ReviewRequest: type: object properties: @@ -484,6 +620,11 @@ components: required: [newPassword] properties: newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} + AssignInternalUserRolesRequest: + type: object + required: [roleIds] + properties: + roleIds: {type: array, items: {type: integer, format: int64}} InternalUserResponse: type: object required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds] @@ -541,6 +682,20 @@ components: routePath: {type: string} httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} sortNo: {type: integer, example: 10} + UpdatePermissionRequest: + type: object + required: [name] + properties: + name: {type: string} + parentId: {type: integer, format: int64} + routePath: {type: string} + httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} + sortNo: {type: integer} + ChangePermissionStatusRequest: + type: object + required: [status] + properties: + status: {type: string, enum: [ACTIVE, DISABLED]} AssignRolesRequest: type: object required: [membershipId, roleIds] diff --git a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java index b2b01833f..5227e8f36 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.iam.controller; import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -10,9 +11,10 @@ import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +@DisplayName("权限快照接口契约") class AuthorizationControllerContractTest { @Test - void 当前权限接口不应依赖角色管理权限() throws Exception { + void currentPermissionsEndpointDoesNotRequireRoleManagementPermission() throws Exception { Method method = AuthorizationController.class.getMethod("permissions", String.class, AuthenticatedUser.class); diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 53e653a26..c4b06bf0b 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -1,5 +1,6 @@ package com.baiyue.iam.controller; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; @@ -9,9 +10,10 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("IAM OpenAPI 契约") class IamOpenApiContractTest { @Test - void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException { + void openApiDeclaresPermissionPaginationAndInternalUserResponseSchema() throws IOException { String document; try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) { assertThat(stream).isNotNull(); @@ -23,6 +25,8 @@ class IamOpenApiContractTest { .contains("PermissionCodesApiResponse:") .contains("TenantPageApiResponse:") .contains("InternalUserListApiResponse:") + .contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:") + .contains("AssignInternalUserRolesRequest:") .contains("membershipId: {type: integer, format: int64}") .contains("roleIds: {type: array, items: {type: integer, format: int64}}") .contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}"); diff --git a/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java index f55bb491c..9f18fc398 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java @@ -1,5 +1,6 @@ package com.baiyue.iam.controller; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.web.util.pattern.PathPatternParser; import org.springframework.http.server.PathContainer; @@ -7,11 +8,12 @@ import org.springframework.http.server.PathContainer; import static org.assertj.core.api.Assertions.assertThat; /** 防止成员数字主键路由再次拦截固定的角色分配路径。 */ +@DisplayName("商户成员路由") class MerchantMemberRouteTest { private final PathPatternParser parser = new PathPatternParser(); @Test - void 成员编辑路由只接受数字主键() { + void memberUpdateRouteAcceptsNumericPrimaryKeyOnly() { var pattern = parser.parse("/api/merchant/v1/iam/members/{membershipId:\\d+}"); assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/123"))).isTrue(); diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java index 34446beb9..eb5a40c10 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java @@ -1,5 +1,6 @@ package com.baiyue.iam.controller; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -9,10 +10,11 @@ import java.lang.reflect.Method; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("平台内部用户接口契约") class PlatformInternalUserContractTest { @Test - void 内部用户列表必须校验列表权限() throws Exception { + void internalUserListRequiresListPermission() throws Exception { Method method = ProfileResourceController.class.getMethod("internalUsers", com.baiyue.common.security.AuthenticatedUser.class); @@ -23,16 +25,18 @@ class PlatformInternalUserContractTest { } @Test - void 平台用户写接口以成员关系主键寻址() { + void platformUserWriteEndpointsAddressMembershipId() { assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}"); assertMapping("changeInternalUserStatus", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status"); assertMapping("resetInternalUserPassword", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password"); + assertMapping("assignInternalUserRoles", + "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles"); } @Test - void 企业资料查看必须校验查看权限() throws Exception { + void tenantProfileReadRequiresViewPermission() throws Exception { Method method = MerchantTenantController.class.getMethod("profile", com.baiyue.common.security.AuthenticatedUser.class); assertEquals("hasAuthority('merchant:profile:view')", diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 509ba8559..2b9edd4d1 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Pattern; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -18,9 +19,10 @@ import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +@DisplayName("平台客户接口契约") class PlatformTenantControllerContractTest { @Test - void 客户分页接口声明权限分页约束和错误状态() throws Exception { + void merchantPageEndpointDeclaresPermissionPaginationConstraintsAndErrorStatuses() throws Exception { Method method = PlatformTenantController.class.getMethod("page", long.class, long.class, String.class, String.class, AuthenticatedUser.class); @@ -39,6 +41,14 @@ class PlatformTenantControllerContractTest { assertEquals(Set.of("200", "400", "401", "403", "422"), statuses); } + @Test + void pendingMerchantListRequiresMerchantApprovalPermission() throws Exception { + Method method = PlatformTenantController.class.getMethod("reviews", AuthenticatedUser.class); + + assertEquals("hasAuthority('platform:customer:review')", + method.getAnnotation(PreAuthorize.class).value()); + } + private T annotation(Method method, int parameter, Class type) { return method.getParameters()[parameter].getAnnotation(type); } diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java index cc6af9acd..ee2f47c7e 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java @@ -1,5 +1,6 @@ package com.baiyue.iam.controller; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.web.bind.annotation.PostMapping; @@ -10,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +@DisplayName("企业资质上传接口契约") class QualificationUploadContractTest { @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index b878d69a8..4829b6f69 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -16,6 +17,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@DisplayName("权限快照服务") class AuthorizationSnapshotServiceTest { @Test void loadsRolesAndPermissionsForActiveMerchantIdentity() { @@ -82,7 +84,7 @@ class AuthorizationSnapshotServiceTest { } @Test - void 空角色和权限查询结果不应触发五百错误() { + void emptyRoleAndPermissionQueriesDoNotTriggerServerError() { AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null); diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index a9219af34..8034e5a10 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; @@ -18,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@DisplayName("商户成员服务") class MemberServiceTest { private final MemberRepository repository = mock(MemberRepository.class); private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); @@ -35,7 +37,7 @@ class MemberServiceTest { when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L)); } - @Test void 成员详情返回联系方式和已有角色() { + @Test void memberDetailsReturnContactInformationAndExistingRoles() { user.setPhone("13800138000"); user.setEmail("member@example.com"); MemberResponse result = service.detail(100L, 20L); assertEquals("测试成员", result.displayName()); @@ -44,12 +46,12 @@ class MemberServiceTest { verifyNoInteractions(auth); } - @Test void 跨租户查询被拒绝() { + @Test void crossTenantLookupIsRejected() { when(repository.findByTenantAndId(200L, 20L)).thenReturn(null); assertThrows(BizException.class, () -> service.detail(200L, 20L)); } - @Test void 企业所有者不可停用() { + @Test void tenantOwnerCannotBeDisabled() { member.setMemberType("OWNER"); BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 20L)); @@ -57,7 +59,7 @@ class MemberServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } - @Test void 停用成员同步认证账号并保存本地状态() { + @Test void disablingMemberSynchronizesAuthAccountAndPersistsLocalStatus() { when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null)); MemberResponse result = service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L); @@ -66,14 +68,14 @@ class MemberServiceTest { verify(repository).saveUser(user); } - @Test void 认证服务失败时不得写入本地状态() { + @Test void authServiceFailureDoesNotPersistLocalStatus() { when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败")); assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L, new ChangeMemberStatusRequest("DISABLED"), 99L)); verify(repository, never()).save(member); } - @Test void 企业所有者不能通过成员编辑接口降级或变更角色() { + @Test void tenantOwnerCannotBeDowngradedOrHaveRolesChangedThroughMemberUpdate() { member.setMemberType("OWNER"); UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null, "EMPLOYEE", null, List.of()); @@ -83,7 +85,7 @@ class MemberServiceTest { verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } - @Test void 企业所有者密码不能被成员管理员重置() { + @Test void tenantOwnerPasswordCannotBeResetByMemberAdministrator() { member.setMemberType("OWNER"); assertThrows(BizException.class, () -> service.resetPassword(100L, 20L, diff --git a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java index ce88c4f79..146c757f5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java @@ -5,16 +5,18 @@ import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.repository.MemberRepository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +@DisplayName("个人资料服务") class PersonalProfileServiceTest { private final MemberRepository repository = mock(MemberRepository.class); private final PersonalProfileService service = new PersonalProfileService(repository); - @Test void 只能修改当前租户中的本人资料() { + @Test void canOnlyModifyOwnProfileWithinCurrentTenant() { IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE"); IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L); when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership); @@ -25,7 +27,7 @@ class PersonalProfileServiceTest { verify(repository).saveUser(user); } - @Test void 跨租户身份被拒绝() { + @Test void crossTenantIdentityIsRejected() { assertThrows(BizException.class, () -> service.get(9L, 2L, "tester")); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index c56da2c79..fd91f9bb7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.iam.entity.IamUser; import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -19,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@DisplayName("平台内部用户服务") class PlatformInternalUserServiceTest { private final MemberRepository repository = mock(MemberRepository.class); private final IamRoleMapper roleMapper = mock(IamRoleMapper.class); @@ -45,7 +47,7 @@ class PlatformInternalUserServiceTest { } @Test - void 列表返回成员主键联系方式和当前角色() { + void listReturnsMembershipIdContactInformationAndCurrentRoles() { when(repository.findByTenant(0L)).thenReturn(List.of(membership)); InternalUserResponse result = service.list().get(0); @@ -57,7 +59,7 @@ class PlatformInternalUserServiceTest { } @Test - void 新建平台用户在Auth创建平台账号并写入IAM成员() { + void createPlatformUserCreatesPlatformAccountInAuthAndMembershipInIam() { IamRole role = new IamRole(); role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); when(roleMapper.selectById(40L)).thenReturn(role); @@ -81,7 +83,7 @@ class PlatformInternalUserServiceTest { } @Test - void 本地创建失败会补偿认证账号且不记录密码() { + void localCreationFailureCompensatesAuthAccountWithoutRecordingPassword() { when(auth.create(anyString(), any())).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE"))); when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null)); @@ -95,7 +97,7 @@ class PlatformInternalUserServiceTest { } @Test - void 不能停用当前登录的平台成员() { + void cannotDisableCurrentlyAuthenticatedPlatformMember() { assertThrows(BizException.class, () -> service.changeStatus(30L, new ChangeInternalUserStatusRequest("DISABLED"), 30L)); @@ -103,7 +105,7 @@ class PlatformInternalUserServiceTest { } @Test - void 状态本地写入失败时恢复Auth且成功后不再远程查询() { + void localStatusPersistenceFailureRestoresAuthAndAvoidsFurtherRemoteLookupsAfterSuccess() { when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user); @@ -118,7 +120,7 @@ class PlatformInternalUserServiceTest { } @Test - void 状态补偿应恢复Auth原状态而不是IAM旧状态() { + void statusCompensationRestoresOriginalAuthStatusInsteadOfOldIamStatus() { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED"))); when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); @@ -134,7 +136,7 @@ class PlatformInternalUserServiceTest { } @Test - void 重置密码只转交Auth不写入IAM() { + void resetPasswordDelegatesOnlyToAuthWithoutWritingIam() { when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1")); @@ -143,4 +145,30 @@ class PlatformInternalUserServiceTest { argThat(value -> "ResetPass1".equals(value.newPassword()))); verify(repository, never()).saveUser(any()); } + + @Test + void roleAssignmentAcceptsEnabledPlatformRolesOnlyAndReturnsLatestView() { + IamRole role = new IamRole(); + role.setId(41L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(41L)).thenReturn(role); + + InternalUserResponse result = service.assignRoles(30L, + new AssignInternalUserRolesRequest(List.of(41L, 41L))); + + assertEquals(List.of(41L), result.roleIds()); + verify(repository).replaceRoles(0L, 30L, List.of(41L)); + } + + @Test + void preventsAssigningMerchantRolesToPlatformUser() { + IamRole merchantRole = new IamRole(); + merchantRole.setId(42L); merchantRole.setTenantId(10001L); + merchantRole.setAppCode("MERCHANT"); merchantRole.setStatus("ACTIVE"); + when(roleMapper.selectById(42L)).thenReturn(merchantRole); + + assertThrows(BizException.class, () -> service.assignRoles(30L, + new AssignInternalUserRolesRequest(List.of(42L)))); + + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 3e8800d72..48ac78566 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -8,12 +8,15 @@ import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest; import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest; import com.baiyue.iam.dto.RbacDtos.ChangeRoleStatusRequest; import com.baiyue.iam.dto.RbacDtos.CreatePermissionRequest; +import com.baiyue.iam.dto.RbacDtos.UpdatePermissionRequest; +import com.baiyue.iam.dto.RbacDtos.ChangePermissionStatusRequest; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamPermission; import com.baiyue.iam.entity.IamRole; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.RbacRepository; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -29,6 +32,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) +@DisplayName("角色与权限服务") class RbacServiceTest { @Mock private RbacRepository repository; @Mock private MemberRepository memberRepository; @@ -42,7 +46,7 @@ class RbacServiceTest { } @Test - void 角色列表返回当前权限用于安全回显() { + void roleListReturnsCurrentPermissionsForSafeView() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role)); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L)); @@ -54,7 +58,7 @@ class RbacServiceTest { } @Test - void 权限列表同码时优先当前租户并去除全局模板重复项() { + void permissionListPrefersCurrentTenantAndRemovesDuplicateGlobalTemplatesWithSameCode() { IamPermission global = permission(21L, 0L, "merchant:member:list"); IamPermission tenant = permission(22L, 100L, "merchant:member:list"); IamPermission fallback = permission(23L, 0L, "merchant:member:status"); @@ -69,7 +73,7 @@ class RbacServiceTest { } @Test - void 跨租户角色访问被拒绝() { + void crossTenantRoleAccessIsRejected() { when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null); BizException exception = assertThrows(BizException.class, @@ -79,7 +83,7 @@ class RbacServiceTest { } @Test - void 系统角色禁止删除() { + void systemRoleCannotBeDeleted() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -91,7 +95,7 @@ class RbacServiceTest { } @Test - void 已分配成员的自定义角色禁止删除() { + void customRoleAssignedToMembersCannotBeDeleted() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(true); @@ -104,7 +108,7 @@ class RbacServiceTest { } @Test - void 空权限集合可以明确清空授权() { + void emptyPermissionSetExplicitlyClearsAuthorization() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); @@ -114,7 +118,7 @@ class RbacServiceTest { } @Test - void 空角色集合可以明确清空成员授权() { + void emptyRoleSetExplicitlyClearsMemberAuthorization() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -126,7 +130,7 @@ class RbacServiceTest { } @Test - void 成员角色回显只返回当前应用域角色() { + void memberRoleViewReturnsRolesFromCurrentApplicationDomainOnly() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -142,7 +146,7 @@ class RbacServiceTest { } @Test - void 同一租户同一应用角色编码重复时返回冲突() { + void duplicateRoleCodeWithinTenantAndApplicationReturnsConflict() { when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true); BizException exception = assertThrows(BizException.class, () -> service.createRole( @@ -153,7 +157,7 @@ class RbacServiceTest { } @Test - void 停用角色不得继续分配给成员() { + void disabledRoleCannotBeAssignedToMember() { IamMembership membership = new IamMembership(); membership.setId(31L); membership.setTenantId(100L); @@ -170,7 +174,7 @@ class RbacServiceTest { } @Test - void 自定义角色可以编辑和停用并保持权限回显() { + void customRoleCanBeUpdatedAndDisabledWhileRetainingPermissionView() { IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM"); when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role); when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L)); @@ -187,7 +191,7 @@ class RbacServiceTest { } @Test - void 未使用的自定义角色可以安全删除() { + void unreferencedCustomRoleCanBeDeletedSafely() { when(repository.findRole(11L, 100L, "MERCHANT")) .thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM")); when(repository.isRoleAssigned(100L, 11L)).thenReturn(false); @@ -198,7 +202,7 @@ class RbacServiceTest { } @Test - void 企业所有者角色不能通过普通成员授权接口修改() { + void tenantOwnerRolesCannotBeChangedThroughRegularMemberAuthorizationEndpoint() { IamMembership owner = new IamMembership(); owner.setId(31L); owner.setTenantId(100L); @@ -213,7 +217,7 @@ class RbacServiceTest { } @Test - void 系统角色权限不能通过普通授权接口修改() { + void systemRolePermissionsCannotBeChangedThroughRegularAuthorizationEndpoint() { when(repository.findRole(11L, 0L, "PLATFORM")) .thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM")); @@ -225,7 +229,7 @@ class RbacServiceTest { } @Test - void 企业所有者系统角色不能分配给普通成员() { + void tenantOwnerSystemRoleCannotBeAssignedToRegularMember() { IamMembership member = new IamMembership(); member.setId(31L); member.setTenantId(100L); @@ -243,7 +247,7 @@ class RbacServiceTest { } @Test - void 权限编码重复时返回稳定冲突且不写数据库() { + void duplicatePermissionCodeReturnsStableConflictWithoutDatabaseWrite() { when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test")) .thenReturn(true); CreatePermissionRequest request = new CreatePermissionRequest( @@ -256,6 +260,67 @@ class RbacServiceTest { verify(repository, never()).insertPermission(any()); } + @Test + void builtInSystemPermissionCannotBeUpdatedDisabledOrDeleted() { + IamPermission builtIn = permission(21L, 0L, "platform:permission:list"); + builtIn.setAppCode("PLATFORM"); + builtIn.setBuiltIn(true); + when(repository.findOwnedPermission(21L, 0L, "PLATFORM")).thenReturn(builtIn); + + BizException exception = assertThrows(BizException.class, () -> service.changePermissionStatus( + 0L, "PLATFORM", 21L, new ChangePermissionStatusRequest("DISABLED"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).updatePermission(any()); + verify(repository, never()).deletePermission(any()); + } + + @Test + void customPermissionCanBeUpdatedAndToggledWithoutChangingCode() { + IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); + when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); + + var updated = service.updatePermission(100L, "MERCHANT", 21L, + new UpdatePermissionRequest("新名称", 0L, "/custom", "PUT", 10)); + var disabled = service.changePermissionStatus(100L, "MERCHANT", 21L, + new ChangePermissionStatusRequest("DISABLED")); + + assertEquals("merchant:custom:edit", updated.getPermissionCode()); + assertEquals("新名称", updated.getPermissionName()); + assertEquals("DISABLED", disabled.getStatus()); + verify(repository, times(2)).updatePermission(custom); + } + + @Test + void permissionResourceWithChildrenOrRoleBindingsRejectsDeletion() { + IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); + when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); + when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(true); + + BizException childFailure = assertThrows(BizException.class, + () -> service.deletePermission(100L, "MERCHANT", 21L)); + assertEquals(ResultCode.CONFLICT, childFailure.getResultCode()); + + when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false); + when(repository.isPermissionGranted(100L, 21L)).thenReturn(true); + BizException bindingFailure = assertThrows(BizException.class, + () -> service.deletePermission(100L, "MERCHANT", 21L)); + assertEquals(ResultCode.CONFLICT, bindingFailure.getResultCode()); + verify(repository, never()).deletePermission(any()); + } + + @Test + void unreferencedCustomPermissionCanBeDeleted() { + IamPermission custom = permission(21L, 100L, "merchant:custom:edit"); + when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom); + when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false); + when(repository.isPermissionGranted(100L, 21L)).thenReturn(false); + + service.deletePermission(100L, "MERCHANT", 21L); + + verify(repository).deletePermission(custom); + } + private IamRole role(Long id, Long tenantId, String appCode, String roleType) { IamRole role = new IamRole(); role.setId(id); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index e034c58d1..d2995bfaa 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -2,7 +2,11 @@ package com.baiyue.iam.service; import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; +import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.ProfileResourceRepository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -12,7 +16,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertThrows; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +@DisplayName("企业租户服务") class TenantServiceTest { @Test @@ -41,4 +49,66 @@ class TenantServiceTest { assertEquals("Alice", result.records().get(0).contactName()); verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); } + + @Test + void platformCanMaintainAndDisableOnlyMerchantTenant() { + TenantRepository repository = mock(TenantRepository.class); + TenantService service = new TenantService(); + ReflectionTestUtils.setField(service, "repository", repository); + IamTenant tenant = merchant(10001L, "ACTIVE"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var updated = service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "New legal", "Alice", "13900139000", "alice@example.com")); + var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); + + assertEquals("New name", updated.tenantName()); + assertEquals("DISABLED", disabled.status()); + verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); + } + + @Test + void platformCannotBypassReviewToActivatePendingTenant() { + TenantRepository repository = mock(TenantRepository.class); + TenantService service = new TenantService(); + ReflectionTestUtils.setField(service, "repository", repository); + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING")); + + BizException exception = assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + } + + @Test + void activeTenantStillRequiresApprovedQualificationToPlaceOrder() { + TenantRepository repository = mock(TenantRepository.class); + ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); + TenantService service = new TenantService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "profileRepository", profileRepository); + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE")); + when(profileRepository.countApprovedQualifications(10001L)).thenReturn(0L, 1L); + + var withoutQualification = service.orderEligibility(10001L); + var withQualification = service.orderEligibility(10001L); + + assertEquals(false, withoutQualification.eligible()); + assertEquals("ACTIVE", withoutQualification.tenantStatus()); + assertEquals(true, withQualification.eligible()); + } + + private IamTenant merchant(long id, String status) { + IamTenant tenant = new IamTenant(); + tenant.setId(id); + tenant.setTenantCode("TEN" + id); + tenant.setTenantType("MERCHANT"); + tenant.setStatus(status); + tenant.setTenantName("Example Merchant"); + tenant.setLegalName("Example Merchant Ltd"); + tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + return tenant; + } } diff --git a/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java index 489117122..5467823ba 100644 --- a/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java @@ -1,5 +1,6 @@ package com.baiyue.integration.config; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.net.URI; @@ -8,6 +9,7 @@ import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("第三方集成配置功能") class IntegrationPropertiesTest { @Test void providerIsConfiguredOnlyWhenEnabledAndAllSecretsExist() { diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java index 707ebee35..1397eb059 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java @@ -1,5 +1,6 @@ package com.baiyue.integration.service.ocr; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -12,6 +13,7 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DisplayName("OCR 图像裁剪功能") class ImageCropperTest { @TempDir Path tempDirectory; diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java index 63343beb9..aca000d3b 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java @@ -1,12 +1,14 @@ package com.baiyue.integration.service.ocr; import com.baiyue.integration.dto.ocr.OcrCandidate; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("OCR 重量候选值筛选功能") class WeightCandidateSelectorTest { private final WeightCandidateSelector selector = new WeightCandidateSelector(); diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java index 8cfa13385..56dfa1b34 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest; import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status; import com.baiyue.integration.provider.ocr.BaiduMeterClient; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.util.unit.DataSize; @@ -24,6 +25,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@DisplayName("OCR 重量批量识别功能") class WeightOcrBatchServiceTest { @TempDir Path tempDirectory; diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java index badb48dab..c36445292 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java @@ -3,6 +3,7 @@ package com.baiyue.integration.service.ocr; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.integration.config.IntegrationProperties; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.util.unit.DataSize; @@ -15,6 +16,7 @@ import java.time.Duration; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DisplayName("OCR 文件存储策略功能") class WeightOcrStoragePolicyTest { @TempDir Path tempDirectory; diff --git a/order/AGENTS.md b/order/AGENTS.md index 0c9988190..89bc39abe 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -30,4 +30,13 @@ - 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。 - 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。 - 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。 +- 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 500。 - 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。 + +## 平台代客接口补充 + +- `POST /api/platform/v1/orders/first-leg?tenantId={tenantId}`:平台代客创建草稿,校验 `platform:order:create`,必须携带 `Idempotency-Key`。 +- `PUT /api/platform/v1/orders/first-leg/{id}?tenantId={tenantId}`:平台编辑草稿,校验 `platform:order:edit`。 +- `POST /api/platform/v1/orders/first-leg/{id}/submit?tenantId={tenantId}`:平台代客提交订单,校验 `platform:order:submit`,必须携带 `Idempotency-Key`。 +- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以租户、操作人、操作和幂等键建立唯一约束。 +- 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。 diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 046c3c0d9..020202d32 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -40,6 +41,71 @@ public class PlatformFirstLegOrderController { @Resource private OrderApplicationService service; + @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿") + @Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功或幂等重放成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数或幂等键无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台创建权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或请求处理中"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用") + }) + @PostMapping + @PreAuthorize("hasAuthority('platform:order:create')") + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, + @Parameter(description = "创建草稿幂等键,同键同请求返回首次结果", required = true) + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody OrderRequests.SaveRequest request) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey)); + } + + @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿") + @Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "订单不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "订单当前不可编辑"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用") + }) + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:order:edit')") + public ApiResponse updateDraft( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, + @Parameter(description = "订单 ID", required = true) @PathVariable long id, + @Valid @RequestBody OrderRequests.SaveRequest request) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + service.updateDraft(scopedTenant, user.userId(), id, request); + return ApiResponse.success(null); + } + + @BusinessAudit(event = "ORDER_SUBMIT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台代客提交订单") + @Operation(summary = "平台代客提交订单", description = "在明确指定的目标租户内提交草稿订单,必须携带幂等键。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "提交成功或幂等重放成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数或幂等键无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台提交权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "订单不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或订单状态不可提交"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用") + }) + @PostMapping("/{id}/submit") + @PreAuthorize("hasAuthority('platform:order:submit')") + public ApiResponse submit( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, + @Parameter(description = "订单 ID", required = true) @PathVariable long id, + @Parameter(description = "提交订单幂等键,同键同请求返回首次结果", required = true) + @RequestHeader("Idempotency-Key") String idempotencyKey) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + service.submit(scopedTenant, user.userId(), user.username(), id, idempotencyKey); + return ApiResponse.success(null); + } + @Operation(summary = "Tenant order query / 租户订单查询", description = "Queries a page of first-leg orders in the explicit target tenant. / 分页查询明确目标租户下的头程订单。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java index 06b6b7a2f..15a138d54 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java @@ -25,6 +25,11 @@ public class OrderEligibilityGuard { } } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM eligibility service is unavailable"); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + // Feign 解码或负载契约漂移不能泄露为通用 500,统一转换为可追踪的下游不可用。 + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 下单资格响应无效"); } } } diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 848379129..f4697a289 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS ord_order ( pickup_method VARCHAR(24) NULL COMMENT '取货方式', receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID', receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照', + receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照', + receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', tax_mode VARCHAR(24) NULL COMMENT '买单或退税', @@ -31,6 +33,8 @@ CREATE TABLE IF NOT EXISTS ord_order ( amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码', shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID', + express_company VARCHAR(64) NULL COMMENT '履约快递公司名称快照', + express_tracking_no VARCHAR(128) NULL COMMENT '履约快递单号', submitted_at DATETIME(3) NULL COMMENT '提交时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql index 8d5e57d88..44cfc7162 100644 --- a/order/src/main/resources/db/phase1-order-extension.sql +++ b/order/src/main/resources/db/phase1-order-extension.sql @@ -28,6 +28,8 @@ DELIMITER ; CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT 'Pickup method'"); CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT 'Domestic receiving warehouse ID'"); CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT 'Domestic receiving warehouse snapshot'"); +CALL phase1_add_column('ord_order', 'receiving_warehouse_address', "VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照'"); +CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照'"); CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'"); CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'"); CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'"); @@ -37,6 +39,8 @@ CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMEN CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon warehouse code'"); CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Amazon Shipment ID'"); CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT 'Amazon Reference ID'"); +CALL phase1_add_column('ord_order', 'express_company', "VARCHAR(64) NULL COMMENT '履约快递公司名称快照'"); +CALL phase1_add_column('ord_order', 'express_tracking_no', "VARCHAR(128) NULL COMMENT '履约快递单号'"); CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT 'SKU number'"); CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT 'UPC number'"); @@ -52,3 +56,22 @@ CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Mo CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'"); DROP PROCEDURE phase1_add_column; + +-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。 +CREATE TABLE IF NOT EXISTS ord_idempotency_record ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录 ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户 ID', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人 ID', + operation VARCHAR(32) NOT NULL COMMENT '操作类型', + resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源 ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(16) NOT NULL COMMENT '处理状态', + result_order_id BIGINT UNSIGNED NULL COMMENT '重放结果订单 ID', + completed_at DATETIME(3) NULL COMMENT '完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), + KEY idx_order_idempotency_created (created_at) +) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录'; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 8e5eeac26..f73723fef 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -42,6 +42,62 @@ paths: description: 幂等键冲突、请求处理中或订单状态冲突 '503': description: 仓库或往来单位下游服务异常 + /api/platform/v1/orders/first-leg: + post: + summary: 平台代客创建订单草稿 + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: 创建成功或同请求重放成功 + '403': + description: 需要 platform:order:create 权限 + '409': + description: 幂等键冲突或请求仍在处理 + /api/platform/v1/orders/first-leg/{id}/submit: + post: + summary: 平台代客提交指定订单 + parameters: + - $ref: '#/components/parameters/OrderId' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '200': + description: 提交成功或同请求重放成功 + '403': + description: 需要 platform:order:submit 权限 + '404': + description: 订单不存在 + '409': + description: 幂等键冲突或当前订单不可提交 + /api/platform/v1/orders/first-leg/{id}: + put: + summary: 平台编辑指定订单草稿 + parameters: + - $ref: '#/components/parameters/OrderId' + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: 编辑成功 + '403': + description: 需要 platform:order:edit 权限 + '404': + description: 订单不存在 + '409': + description: 当前订单不可编辑 components: securitySchemes: bearerAuth: @@ -49,6 +105,13 @@ components: scheme: bearer bearerFormat: JWT parameters: + TenantId: + name: tenantId + in: query + required: true + schema: + type: integer + format: int64 OrderId: name: id in: path diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 08bb1914a..13fe8e123 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.OrderRequests; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -9,9 +10,10 @@ import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("订单接口权限控制") class OrderControllerPermissionTest { @Test - void 商户订单列表和详情具有对应查询权限码() throws Exception { + void merchantOrderListAndDetailRequireCorrespondingPermissions() throws Exception { assertAuthority(MerchantFirstLegOrderController.class, "page", "hasAuthority('merchant:order:list')", AuthenticatedUser.class, OrderRequests.QueryRequest.class); assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')", @@ -19,13 +21,23 @@ class OrderControllerPermissionTest { } @Test - void 平台订单列表和详情使用已配置的订单列表权限码() throws Exception { + void platformOrderListAndDetailUseConfiguredListPermission() throws Exception { assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')", AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')", AuthenticatedUser.class, long.class, long.class); } + @Test + void platformAssistedCreateAndEditUseDedicatedPermissions() throws Exception { + assertAuthority(PlatformFirstLegOrderController.class, "create", "hasAuthority('platform:order:create')", + AuthenticatedUser.class, long.class, String.class, OrderRequests.SaveRequest.class); + assertAuthority(PlatformFirstLegOrderController.class, "updateDraft", "hasAuthority('platform:order:edit')", + AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class); + assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')", + AuthenticatedUser.class, long.class, long.class, String.class); + } + private void assertAuthority(Class controller, String methodName, String expected, Class... parameterTypes) throws NoSuchMethodException { Method method = controller.getDeclaredMethod(methodName, parameterTypes); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index ef24b75cd..7844d3e0f 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -5,14 +5,16 @@ import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +@DisplayName("订单状态流转") class OrderApplicationServiceStateTest { @Test - void 重复提交返回明确冲突且不重复写库() { + void repeatedSubmissionReturnsConflictWithoutDuplicatePersistence() { OrderRepository repository=mock(OrderRepository.class); OrderEntity order=new OrderEntity(); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.SUBMITTED); when(repository.findOwned(10001,1)).thenReturn(order); diff --git a/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java new file mode 100644 index 000000000..855787fe6 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java @@ -0,0 +1,32 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.TenantEligibilityClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("订单下单资格校验") +class OrderEligibilityGuardTest { + + @Test + void downstreamResponseDecodingFailureReturnsServiceUnavailable() { + TenantEligibilityClient client = mock(TenantEligibilityClient.class); + when(client.check("internal-token", 10001L)) + .thenThrow(new IllegalStateException("响应契约漂移")); + + OrderEligibilityGuard guard = new OrderEligibilityGuard(); + ReflectionTestUtils.setField(guard, "client", client); + ReflectionTestUtils.setField(guard, "internalToken", "internal-token"); + + assertThatThrownBy(() -> guard.requireApproved(10001L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index fcbe5e8d7..36349aeb8 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderIdempotencyRecordEntity; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.dao.DuplicateKeyException; @@ -21,12 +22,14 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@DisplayName("订单幂等处理") class OrderIdempotencyServiceTest { @Test - void 同键同请求返回首次创建的订单ID() { + void sameKeyAndRequestReturnsFirstCreatedOrderId() { OrderRepository repository = mock(OrderRepository.class); OrderIdempotencyRecordEntity existing = completed(998L); AtomicReference attempted = new AtomicReference<>(); @@ -47,7 +50,7 @@ class OrderIdempotencyServiceTest { } @Test - void 同键不同请求返回冲突() { + void sameKeyWithDifferentRequestReturnsConflict() { OrderRepository repository = mock(OrderRepository.class); doAnswer(invocation -> { throw new DuplicateKeyException("并发唯一键冲突"); }) .when(repository).insertIdempotency(any()); @@ -64,7 +67,7 @@ class OrderIdempotencyServiceTest { } @Test - void 并发请求尚未完成时返回冲突() { + void inProgressConcurrentRequestReturnsConflict() { OrderRepository repository = mock(OrderRepository.class); OrderIdempotencyRecordEntity existing = new OrderIdempotencyRecordEntity(); existing.setStatus("PROCESSING"); @@ -84,7 +87,25 @@ class OrderIdempotencyServiceTest { } @Test - void 不存在订单统一返回404() { + void submitOrderSameKeyReplayDoesNotEnterStateTransition() { + OrderRepository repository = mock(OrderRepository.class); + OrderIdempotencyRecordEntity existing = completed(998L); + doAnswer(invocation -> { + OrderIdempotencyRecordEntity attempted = invocation.getArgument(0); + existing.setRequestHash(attempted.getRequestHash()); + throw new DuplicateKeyException("并发唯一键冲突"); + }).when(repository).insertIdempotency(any()); + when(repository.findIdempotency(10001L, 20001L, "SUBMIT_ORDER", "submit-key")) + .thenReturn(existing); + + service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key"); + + verify(repository, never()).findOwned(anyLong(), anyLong()); + verify(repository, never()).saveOrder(any()); + } + + @Test + void missingOrderReturnsNotFound() { OrderRepository repository = mock(OrderRepository.class); assertThatThrownBy(() -> service(repository).detail(10001L, 404L)) @@ -94,23 +115,42 @@ class OrderIdempotencyServiceTest { } @Test - void SQL唯一键包含租户操作者操作和幂等键() throws Exception { + void sqlUniqueKeyContainsTenantOperatorActionAndIdempotencyKey() throws Exception { String sql = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); assertThat(sql).contains( "UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key)"); } @Test - void OpenAPI声明创建和提交均要求幂等键() throws Exception { + void openApiDeclaresIdempotencyKeyForCreateAndSubmit() throws Exception { String openapi = new ClassPathResource("openapi/order-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi) .contains("/api/merchant/v1/orders/first-leg:") .contains("/api/merchant/v1/orders/first-leg/{id}/submit:") + .contains("/api/platform/v1/orders/first-leg:") + .contains("/api/platform/v1/orders/first-leg/{id}/submit:") + .contains("platform:order:create") + .contains("platform:order:edit") + .contains("platform:order:submit") .contains("name: Idempotency-Key") .contains("required: true"); } + @Test + void newAndExistingDatabaseScriptsContainActualOrderWriteFields() throws Exception { + String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase1-order-extension.sql") + .getContentAsString(StandardCharsets.UTF_8); + + for (String column : new String[] { + "receiving_warehouse_address", "receiving_time", "express_company", "express_tracking_no" + }) { + assertThat(baseline).contains(column); + assertThat(migration).contains(column); + } + } + private OrderApplicationService service(OrderRepository repository) { OrderApplicationService service = new OrderApplicationService(); ReflectionTestUtils.setField(service, "repository", repository); diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 06a7ec8f3..417ac2277 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.PartnerClient; import com.baiyue.order.client.WarehouseClient; import com.baiyue.order.dto.OrderRequests; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.util.Collections; @@ -13,9 +14,10 @@ import java.util.List; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +@DisplayName("订单主数据校验") class OrderMasterDataGuardTest { @Test - void 正常校验返回服务端可信快照() { + void validMasterDataReturnsTrustedServerSnapshot() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE"); var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null); @@ -28,14 +30,14 @@ class OrderMasterDataGuardTest { assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓"); } @Test - void 停用或跨租户主数据被拒绝() { + void inactiveOrCrossTenantMasterDataIsRejected() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null)); assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L))) .isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用"); } @Test - void 主数据响应含空元素时返回受控业务错误而不是空指针异常() { + void masterDataResponseWithNullElementReturnsControlledBusinessError() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE"); var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"), @@ -49,7 +51,7 @@ class OrderMasterDataGuardTest { .isEqualTo(ResultCode.VALIDATION_FAILED); } @Test - void 缺失快照名称的主数据响应按服务不可用处理() { + void masterDataResponseMissingSnapshotNameIsServiceUnavailable() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE"))); @@ -60,7 +62,7 @@ class OrderMasterDataGuardTest { verifyNoInteractions(warehouses); } @Test - void 下游非Feign运行时异常映射为服务不可用() { + void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() { WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败")); diff --git a/scripts/migrate-local-qualification-file.ps1 b/scripts/migrate-local-qualification-file.ps1 new file mode 100644 index 000000000..52a74dcfe --- /dev/null +++ b/scripts/migrate-local-qualification-file.ps1 @@ -0,0 +1,86 @@ +param( + [Parameter(Mandatory = $true)][string]$ImagePath, + [Parameter(Mandatory = $true)][string]$StorageRoot, + [Parameter(Mandatory = $true)][UInt64]$TenantId, + [Parameter(Mandatory = $true)][UInt64]$QualificationId, + [Parameter(Mandatory = $true)][string]$OldDocumentUrl, + [string]$OutputDirectory = ".\reports\qualification-migration" +) + +$ErrorActionPreference = "Stop" +$source = (Resolve-Path -LiteralPath $ImagePath).Path +$file = Get-Item -LiteralPath $source +if ($file.Length -le 0 -or $file.Length -gt 5MB) { + throw "资质图片必须大于 0 字节且不超过 5 MB。" +} + +$bytes = [System.IO.File]::ReadAllBytes($source) +$isJpeg = $bytes.Length -ge 3 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xD8 -and $bytes[2] -eq 0xFF +$pngSignature = [byte[]](0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) +$isPng = $bytes.Length -ge 8 +for ($index = 0; $isPng -and $index -lt $pngSignature.Length; $index++) { + $isPng = $bytes[$index] -eq $pngSignature[$index] +} +if (-not $isJpeg -and -not $isPng) { + throw "资质文件签名不是有效的 JPG 或 PNG。" +} + +$extension = if ($isPng) { ".png" } else { ".jpg" } +$fileName = "{0}{1}" -f ([guid]::NewGuid().ToString()), $extension +$qualificationDirectory = Join-Path (Join-Path $StorageRoot "qualifications") $TenantId +$target = Join-Path $qualificationDirectory $fileName +New-Item -ItemType Directory -Force -Path $qualificationDirectory | Out-Null +Copy-Item -LiteralPath $source -Destination $target -ErrorAction Stop + +$sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash +$targetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash +if ($sourceHash -ne $targetHash) { + Remove-Item -LiteralPath $target -Force + throw "复制后的文件校验和不一致,迁移已终止并清理目标文件。" +} + +$newUrl = "/api/merchant/v1/files/qualifications/$fileName" +$escapedOldUrl = $OldDocumentUrl.Replace("'", "''") +$escapedNewUrl = $newUrl.Replace("'", "''") +$output = [System.IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Force -Path $output | Out-Null + +$forwardSql = @" +START TRANSACTION; +UPDATE baiyue_iam.iam_tenant_qualification +SET document_url = '$escapedNewUrl' +WHERE id = $QualificationId + AND tenant_id = $TenantId + AND document_url = '$escapedOldUrl' + AND deleted = 0; +-- 仅当 ROW_COUNT() = 1 时提交,否则回滚并删除:$target +COMMIT; +"@ +$rollbackSql = @" +START TRANSACTION; +UPDATE baiyue_iam.iam_tenant_qualification +SET document_url = '$escapedOldUrl' +WHERE id = $QualificationId + AND tenant_id = $TenantId + AND document_url = '$escapedNewUrl' + AND deleted = 0; +-- 仅当 ROW_COUNT() = 1 时提交;确认旧文件可用后再删除:$target +COMMIT; +"@ + +$forwardPath = Join-Path $output "qualification-$QualificationId-forward.sql" +$rollbackPath = Join-Path $output "qualification-$QualificationId-rollback.sql" +[System.IO.File]::WriteAllText($forwardPath, $forwardSql, [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($rollbackPath, $rollbackSql, [System.Text.UTF8Encoding]::new($false)) + +[pscustomobject]@{ + TenantId = $TenantId + QualificationId = $QualificationId + DocumentUrl = $newUrl + PhysicalFile = $target + Sha256 = $targetHash + ForwardSql = $forwardPath + RollbackSql = $rollbackPath + MerchantPreview = $newUrl + PlatformPreview = "/api/platform/v1/files/qualifications/$TenantId/$fileName" +} diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 6cae926bf..3e3b106cb 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -16,11 +16,13 @@ | 内部往来单位有效性校验 | `controller/InternalPartnerController.java` | | 平台往来单位增改查与启停 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java` | | 平台快递公司增改查与启停 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java` | +| 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` | +| 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` | | 仓库业务编排 | `service/WarehouseApplicationService.java` | | 往来单位查询与停用过滤 | `service/PartnerQueryService.java` | | 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` | | 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/` | -| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql` | +| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` | | 测试 | `src/test/java/com/baiyue/warehouse/` | ## 修改与验证 @@ -28,4 +30,6 @@ - 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。 - 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。 - 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。 +- 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。 +- `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。 - 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java new file mode 100644 index 000000000..d0f7e9680 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java @@ -0,0 +1,31 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.web.CorrelationContext; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataAccessException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** 仓储数据库不可用或结构漂移时返回可识别的 503,并保留完整链路日志。 */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages = "com.baiyue.warehouse.controller") +public class WarehouseDataAccessExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(WarehouseDataAccessExceptionHandler.class); + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity> handle(DataAccessException exception, HttpServletRequest request) { + String requestId = CorrelationContext.requestId(request); + String traceId = CorrelationContext.traceId(request); + log.error("Warehouse data access failure requestId={} traceId={}", requestId, traceId, exception); + ResultCode code = ResultCode.SERVICE_UNAVAILABLE; + return ResponseEntity.status(code.httpStatus()).body(ApiResponse.failure(code, + "仓储数据库暂不可用,请检查数据源和数据库迁移", requestId, traceId)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java index e519c0841..c60ea108a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java @@ -1,11 +1,13 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.service.WarehouseSchemaReadinessService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; import java.util.Map; @@ -16,11 +18,22 @@ import java.util.Map; @RestController @RequestMapping("/api/platform/v1/warehouses") public class WarehouseHealthController { + private final WarehouseSchemaReadinessService readinessService; + + public WarehouseHealthController(WarehouseSchemaReadinessService readinessService) { + this.readinessService = readinessService; + } + @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-service. / 返回 warehouse-service 的逻辑运行状态。") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") @GetMapping("/status") - public ApiResponse> status() { - return ApiResponse.success(Map.of("service", "warehouse-service", "status", "UP")); + public ResponseEntity>> status() { + var readiness = readinessService.readiness(); + var body = ApiResponse.success(Map.of( + "service", "warehouse-service", + "status", readiness.status(), + "message", readiness.message())); + return ResponseEntity.status(readiness.ready() ? 200 : 503).body(body); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java index 11afddba8..356eb1c62 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java @@ -4,26 +4,43 @@ import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.Setter; /** 仓储域内的租户往来单位配置。 */ +@Schema(name = "Partner", description = "租户维度往来单位主数据") @Getter @Setter @TableName("wh_partner") public class PartnerEntity extends BaseEntity { + @Schema(description = "往来单位记录 ID", example = "191000000000000001") @TableId(type = IdType.ASSIGN_ID) private Long id; + @Schema(description = "所属租户 ID", example = "10001") private Long tenantId; + @Schema(description = "租户内唯一往来单位编码", example = "CUS-001") private String partnerCode; + @Schema(description = "往来单位名称", example = "深圳示例贸易有限公司") private String partnerName; + @Schema(description = "类型", example = "CUSTOMER", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"}) private String partnerType; + @Schema(description = "联系人") private String contactName; + @Schema(description = "联系电话") private String contactPhone; + @Schema(description = "联系邮箱") private String contactEmail; + @Schema(description = "国家二字码", example = "CN") private String countryCode; + @Schema(description = "省或州") private String province; + @Schema(description = "城市") private String city; + @Schema(description = "区县") private String district; + @Schema(description = "详细地址") private String addressLine; + @Schema(description = "邮政编码") private String postalCode; + @Schema(description = "状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) private String status; } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java new file mode 100644 index 000000000..e3cee01be --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -0,0 +1,40 @@ +package com.baiyue.warehouse.service; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 仓储库表结构就绪检查。状态接口通过真实字段探针判断数据库是否已执行一期迁移, + * 避免服务进程存活但所有业务查询因缺表或缺列返回 500。 + */ +@Service +public class WarehouseSchemaReadinessService { + private static final List PROBES = List.of( + "SELECT id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, status, deleted FROM wh_warehouse LIMIT 0", + "SELECT id, tenant_id, warehouse_id, address_type, country_code, city, address_line1, deleted FROM wh_warehouse_address LIMIT 0", + "SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0", + "SELECT id, tenant_id, partner_code, partner_name, partner_type, status, deleted FROM wh_partner LIMIT 0", + "SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0" + ); + + private final JdbcTemplate jdbcTemplate; + + public WarehouseSchemaReadinessService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Readiness readiness() { + try { + PROBES.forEach(jdbcTemplate::execute); + return new Readiness(true, "UP", "仓储数据库表结构已就绪"); + } catch (DataAccessException exception) { + return new Readiness(false, "DEGRADED", "仓储数据库表结构未就绪,请执行一期迁移脚本"); + } + } + + public record Readiness(boolean ready, String status, String message) { + } +} diff --git a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql new file mode 100644 index 000000000..ed6813a99 --- /dev/null +++ b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql @@ -0,0 +1,147 @@ +-- 既有 baiyue_warehouse 的一期增量迁移(只增不删,可重复执行)。 +-- 执行顺序:warehouse.sql、warehouse-partner.sql、warehouse-express-company.sql、 +-- 本文件、warehouse-seed.sql。应用不会在启动时自动执行这些脚本,必须由发布流程执行。 +SET NAMES utf8mb4; +USE baiyue_warehouse; + +-- MySQL 5.7/8.0 各小版本对条件补列语法的支持不一致。 +-- 统一先查 information_schema,再通过动态 SQL 补列,确保脚本可重复执行。 +DROP PROCEDURE IF EXISTS add_column_if_missing; +DELIMITER $$ +CREATE PROCEDURE add_column_if_missing( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @add_column_sql = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE add_column_statement FROM @add_column_sql; + EXECUTE add_column_statement; + DEALLOCATE PREPARE add_column_statement; + END IF; +END$$ +DELIMITER ; + +-- 早期 wh_warehouse 缺少的业务与审计字段。 +CALL add_column_if_missing('wh_warehouse', 'warehouse_type', 'VARCHAR(32) NOT NULL DEFAULT ''DOMESTIC'' COMMENT ''仓库类型'' AFTER warehouse_name'); +CALL add_column_if_missing('wh_warehouse', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' COMMENT ''所在国家二字码'' AFTER warehouse_type'); +CALL add_column_if_missing('wh_warehouse', 'timezone', 'VARCHAR(64) NOT NULL DEFAULT ''Asia/Shanghai'' COMMENT ''IANA时区'' AFTER country_code'); +CALL add_column_if_missing('wh_warehouse', 'contact_name', 'VARCHAR(64) NULL AFTER timezone'); +CALL add_column_if_missing('wh_warehouse', 'contact_phone', 'VARCHAR(32) NULL AFTER contact_name'); +CALL add_column_if_missing('wh_warehouse', 'contact_email', 'VARCHAR(128) NULL AFTER contact_phone'); +CALL add_column_if_missing('wh_warehouse', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER contact_email'); +CALL add_column_if_missing('wh_warehouse', 'remark', 'VARCHAR(500) NULL AFTER status'); +CALL add_column_if_missing('wh_warehouse', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_warehouse', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL add_column_if_missing('wh_warehouse_address', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_warehouse_address', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); +CALL add_column_if_missing('wh_warehouse_address', 'address_type', 'VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' AFTER warehouse_id'); +CALL add_column_if_missing('wh_warehouse_address', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER address_type'); +CALL add_column_if_missing('wh_warehouse_address', 'state_province', 'VARCHAR(100) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'city', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER state_province'); +CALL add_column_if_missing('wh_warehouse_address', 'district', 'VARCHAR(100) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'postal_code', 'VARCHAR(20) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'address_line1', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER postal_code'); +CALL add_column_if_missing('wh_warehouse_address', 'address_line2', 'VARCHAR(255) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'latitude', 'DECIMAL(10,7) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'longitude', 'DECIMAL(10,7) NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse_address', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_warehouse_address', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_warehouse_address', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_warehouse_address', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL add_column_if_missing('wh_service_capability', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_service_capability', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id'); +CALL add_column_if_missing('wh_service_capability', 'capability_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER warehouse_id'); +CALL add_column_if_missing('wh_service_capability', 'capability_name', 'VARCHAR(100) NOT NULL DEFAULT '''' AFTER capability_code'); +CALL add_column_if_missing('wh_service_capability', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1'); +CALL add_column_if_missing('wh_service_capability', 'cutoff_time', 'TIME NULL'); +CALL add_column_if_missing('wh_service_capability', 'description', 'VARCHAR(500) NULL'); +CALL add_column_if_missing('wh_service_capability', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_service_capability', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_service_capability', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_service_capability', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_service_capability', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_service_capability', 'version', 'INT NOT NULL DEFAULT 0'); + +CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); +CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); +CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); +CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); +CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); +CALL add_column_if_missing('wh_partner', 'province', 'VARCHAR(64) NULL'); +CALL add_column_if_missing('wh_partner', 'city', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER province'); +CALL add_column_if_missing('wh_partner', 'district', 'VARCHAR(64) NULL'); +CALL add_column_if_missing('wh_partner', 'address_line', 'VARCHAR(255) NOT NULL DEFAULT '''' AFTER district'); +CALL add_column_if_missing('wh_partner', 'postal_code', 'VARCHAR(20) NULL'); +CALL add_column_if_missing('wh_partner', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER postal_code'); +CALL add_column_if_missing('wh_partner', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_partner', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); + +-- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 +CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); +CALL add_column_if_missing('wh_express_company', 'company_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); +CALL add_column_if_missing('wh_express_company', 'company_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER company_code'); +CALL add_column_if_missing('wh_express_company', 'service_phone', 'VARCHAR(32) NULL'); +CALL add_column_if_missing('wh_express_company', 'website', 'VARCHAR(255) NULL'); +CALL add_column_if_missing('wh_express_company', 'tracking_url_template', 'VARCHAR(500) NULL'); +CALL add_column_if_missing('wh_express_company', 'status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' AFTER tracking_url_template'); +CALL add_column_if_missing('wh_express_company', 'remark', 'VARCHAR(500) NULL'); +CALL add_column_if_missing('wh_express_company', 'created_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_express_company', 'created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_express_company', 'updated_by', 'BIGINT UNSIGNED NULL'); +CALL add_column_if_missing('wh_express_company', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); +CALL add_column_if_missing('wh_express_company', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); +CALL add_column_if_missing('wh_express_company', 'version', 'INT NOT NULL DEFAULT 0'); + +DROP PROCEDURE IF EXISTS add_column_if_missing; + +-- MySQL 8 的 ALTER ADD INDEX 不在所有小版本都支持 IF NOT EXISTS,使用元数据判断以保证重复执行安全。 +SET @schema_name = DATABASE(); +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_warehouse' AND index_name = 'idx_wh_tenant_status') = 0, + 'ALTER TABLE wh_warehouse ADD KEY idx_wh_tenant_status (tenant_id, status, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_warehouse_address' AND index_name = 'idx_address_warehouse') = 0, + 'ALTER TABLE wh_warehouse_address ADD KEY idx_address_warehouse (tenant_id, warehouse_id, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_service_capability' AND index_name = 'idx_capability_query') = 0, + 'ALTER TABLE wh_service_capability ADD KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_partner' AND index_name = 'idx_wh_partner_tenant_status') = 0, + 'ALTER TABLE wh_partner ADD KEY idx_wh_partner_tenant_status (tenant_id, status, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'idx_wh_express_tenant_status') = 0, + 'ALTER TABLE wh_express_company ADD KEY idx_wh_express_tenant_status (tenant_id, status, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_partner' AND index_name = 'uk_wh_partner_tenant_code') = 0, + 'ALTER TABLE wh_partner ADD UNIQUE KEY uk_wh_partner_tenant_code (tenant_id, partner_code, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_code') = 0, + 'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; diff --git a/warehouse/src/main/resources/db/warehouse-seed.sql b/warehouse/src/main/resources/db/warehouse-seed.sql index bba655a92..6ded97832 100644 --- a/warehouse/src/main/resources/db/warehouse-seed.sql +++ b/warehouse/src/main/resources/db/warehouse-seed.sql @@ -10,3 +10,8 @@ ON DUPLICATE KEY UPDATE address_line1=VALUES(address_line1); INSERT INTO wh_service_capability(id,tenant_id,warehouse_id,capability_code,capability_name,enabled,cutoff_time,description) VALUES(190000000000000021,10001,190000000000000001,'RECEIVING','头程收货',1,'16:00:00','工作日16点前预约可当日收货') ON DUPLICATE KEY UPDATE enabled=1,cutoff_time=VALUES(cutoff_time); + +-- 一期联调快递公司主数据,供平台维护和订单履约回显验证。 +INSERT INTO wh_express_company(id,tenant_id,company_code,company_name,service_phone,website,tracking_url_template,status,remark) +VALUES(192000000000000001,10001,'SF','顺丰速运','95338','https://www.sf-express.com','https://www.sf-express.com/chn/sc/dynamic_function/waybill/#search/bill-number/{trackingNo}','ACTIVE','一期联调样本,可按租户回收') +ON DUPLICATE KEY UPDATE company_name=VALUES(company_name),status='ACTIVE'; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java index 598783c3a..3929ef91e 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.warehouse.controller; import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -13,9 +14,11 @@ import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("平台主数据控制器契约") class PlatformMasterDataControllerContractTest { @Test - void 往来单位分页路径权限和接口说明完整() throws Exception { + @DisplayName("往来单位分页路径权限和接口说明完整") + void shouldDeclareCompletePartnerListContract() throws Exception { assertContract(PlatformPartnerController.class, "platform:counterparty:list", "/api/platform/v1/partners"); assertMethod(PlatformPartnerController.class, "detail", GetMapping.class, "platform:counterparty:list"); assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create"); @@ -24,7 +27,8 @@ class PlatformMasterDataControllerContractTest { } @Test - void 快递公司分页路径权限和接口说明完整() throws Exception { + @DisplayName("快递公司分页路径权限和接口说明完整") + void shouldDeclareCompleteExpressCompanyListContract() throws Exception { assertContract(PlatformExpressCompanyController.class, "platform:express:list", "/api/platform/v1/express-companies"); assertMethod(PlatformExpressCompanyController.class, "detail", GetMapping.class, "platform:express:list"); assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create"); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java new file mode 100644 index 000000000..e0800ad0e --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java @@ -0,0 +1,30 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("仓库数据访问异常处理器") +class WarehouseDataAccessExceptionHandlerTest { + @Test + @DisplayName("数据源故障统一返回503并保留请求标识") + void shouldReturnServiceUnavailableAndPreserveRequestIdsForDataSourceFailure() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getAttribute("requestId")).thenReturn("request-1"); + when(request.getAttribute("traceId")).thenReturn("trace-1"); + + var response = new WarehouseDataAccessExceptionHandler().handle( + new DataAccessResourceFailureException("database unavailable"), request); + + assertThat(response.getStatusCode().value()).isEqualTo(503); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE.code()); + assertThat(response.getBody().message()).contains("数据库"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java new file mode 100644 index 000000000..eb9efb506 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.warehouse.service.WarehouseSchemaReadinessService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("仓库健康检查控制器") +class WarehouseHealthControllerTest { + @Test + @DisplayName("表结构漂移时状态接口返回503") + void shouldReturnServiceUnavailableWhenSchemaDrifts() { + WarehouseSchemaReadinessService service = mock(WarehouseSchemaReadinessService.class); + when(service.readiness()).thenReturn( + new WarehouseSchemaReadinessService.Readiness(false, "DEGRADED", "需要执行迁移")); + + var response = new WarehouseHealthController(service).status(); + + assertThat(response.getStatusCode().value()).isEqualTo(503); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data()).containsEntry("status", "DEGRADED"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java index e27391153..b6d3f3ba1 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java @@ -9,6 +9,7 @@ import com.baiyue.warehouse.mapper.ExpressCompanyMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -18,14 +19,16 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@DisplayName("快递公司仓储") class ExpressCompanyRepositoryTest { @BeforeAll - static void 初始化实体字段缓存() { + static void initializeEntityFieldCache() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), ExpressCompanyEntity.class); } @Test - void 平台分页强制租户并支持筛选() { + @DisplayName("平台分页强制租户并支持筛选") + void shouldEnforceTenantAndSupportFilteringForPlatformPage() { ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java index 1659ecbb7..be49d534b 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.mapper.PartnerMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeAll; import org.mockito.ArgumentCaptor; @@ -16,18 +17,23 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +@DisplayName("往来单位仓储") class PartnerRepositoryTest { - @BeforeAll static void 初始化实体字段缓存() { + @BeforeAll static void initializeEntityFieldCache() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), PartnerEntity.class); } - @Test void 候选查询强制租户和启用状态() { + @Test + @DisplayName("候选查询强制租户和启用状态") + void shouldEnforceTenantAndActiveStatusForCandidateQuery() { PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of()); new PartnerRepository(mapper).activeCandidates(10001,"示例","CUSTOMER"); @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment(); assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE","CUSTOMER"); } - @Test void 详情查询强制租户和启用状态() { + @Test + @DisplayName("详情查询强制租户和启用状态") + void shouldEnforceTenantAndActiveStatusForDetailQuery() { PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectOne(any())).thenReturn(null); new PartnerRepository(mapper).findActive(10002,9); @SuppressWarnings("unchecked") ArgumentCaptor> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class); @@ -35,7 +41,9 @@ class PartnerRepositoryTest { assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE"); } - @Test void 平台分页强制租户并支持筛选() { + @Test + @DisplayName("平台分页强制租户并支持筛选") + void shouldEnforceTenantAndSupportFilteringForPlatformPage() { PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index ae8c31feb..a10be05f7 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -1,5 +1,6 @@ package com.baiyue.warehouse.repository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -8,9 +9,11 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("仓库数据库结构契约") class WarehouseSchemaContractTest { @Test - void 仓库三张表包含实体全部持久化字段() throws IOException { + @DisplayName("仓库三张表包含实体全部持久化字段") + void shouldContainAllPersistentFieldsInWarehouseTables() throws IOException { String sql = read("warehouse.sql"); assertColumns(sql, "wh_warehouse", "warehouse_code", "warehouse_name", "warehouse_type", "country_code", "timezone", "contact_name", "contact_phone", "contact_email", "status", "remark"); @@ -22,7 +25,8 @@ class WarehouseSchemaContractTest { } @Test - void 往来单位表包含实体全部持久化字段() throws IOException { + @DisplayName("往来单位表包含实体全部持久化字段") + void shouldContainAllPersistentFieldsInPartnerTable() throws IOException { String sql = read("warehouse-partner.sql"); assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", @@ -31,13 +35,32 @@ class WarehouseSchemaContractTest { } @Test - void 快递公司表包含实体全部持久化字段() throws IOException { + @DisplayName("快递公司表包含实体全部持久化字段") + void shouldContainAllPersistentFieldsInExpressCompanyTable() throws IOException { String sql = read("warehouse-express-company.sql"); assertColumns(sql, "wh_express_company", "company_code", "company_name", "service_phone", "website", "tracking_url_template", "status", "remark"); assertBaseColumns(sql); } + @Test + @DisplayName("既有库增量迁移覆盖全部仓储实体表和列表索引") + void shouldCoverAllWarehouseTablesAndListIndexesInIncrementalMigration() throws IOException { + String sql = read("phase1-warehouse-schema-migration.sql"); + assertThat(sql).contains("FROM information_schema.columns") + .contains("CREATE PROCEDURE add_column_if_missing") + .contains("CALL add_column_if_missing('wh_warehouse'") + .contains("CALL add_column_if_missing('wh_warehouse_address'") + .contains("CALL add_column_if_missing('wh_service_capability'") + .contains("CALL add_column_if_missing('wh_partner'") + .contains("CALL add_column_if_missing('wh_express_company'") + .contains("DROP PROCEDURE IF EXISTS add_column_if_missing") + .doesNotContain("ADD COLUMN IF NOT EXISTS") + .contains("idx_wh_tenant_status") + .contains("idx_wh_partner_tenant_status") + .contains("idx_wh_express_tenant_status"); + } + private static String read(String file) throws IOException { return Files.readString(Path.of("src/main/resources/db", file)); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java index 6eadbc51b..b83671c6e 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.repository.ExpressCompanyRepository; import com.baiyue.warehouse.repository.PartnerRepository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.dao.DataIntegrityViolationException; @@ -20,9 +21,11 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@DisplayName("平台主数据维护服务") class PlatformMasterDataManagementServiceTest { @Test - void 往来单位详情强制租户隔离() { + @DisplayName("往来单位详情强制租户隔离") + void shouldEnforceTenantIsolationForPartnerDetail() { PartnerRepository repository = mock(PartnerRepository.class); PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); @@ -31,7 +34,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位编码在租户内必须唯一() { + @DisplayName("往来单位编码在租户内必须唯一") + void shouldRequireUniquePartnerCodeWithinTenant() { PartnerRepository repository = mock(PartnerRepository.class); when(repository.existsCode(10001L, "CUS-001", null)).thenReturn(true); PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); @@ -42,7 +46,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位并发重复编码统一返回业务冲突() { + @DisplayName("往来单位并发重复编码统一返回业务冲突") + void shouldReturnBusinessConflictForConcurrentDuplicatePartnerCode() { PartnerRepository repository = mock(PartnerRepository.class); doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository); @@ -52,7 +57,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位新增写入租户和请求字段() { + @DisplayName("往来单位新增写入租户和请求字段") + void shouldPersistTenantAndRequestFieldsWhenCreatingPartner() { PartnerRepository repository = mock(PartnerRepository.class); doAnswer(invocation -> { PartnerEntity entity = invocation.getArgument(0); @@ -71,7 +77,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位编辑保留归属并更新字段() { + @DisplayName("往来单位编辑保留归属并更新字段") + void shouldPreserveOwnershipAndUpdateFieldsWhenEditingPartner() { PartnerRepository repository = mock(PartnerRepository.class); PartnerEntity entity = new PartnerEntity(); entity.setId(19L); @@ -87,7 +94,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 往来单位停用保留主数据() { + @DisplayName("往来单位停用保留主数据") + void shouldPreservePartnerMasterDataWhenDeactivating() { PartnerRepository repository = mock(PartnerRepository.class); PartnerEntity entity = new PartnerEntity(); entity.setId(8L); @@ -103,7 +111,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司详情强制租户隔离() { + @DisplayName("快递公司详情强制租户隔离") + void shouldEnforceTenantIsolationForExpressCompanyDetail() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); @@ -112,7 +121,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司编码在租户内必须唯一() { + @DisplayName("快递公司编码在租户内必须唯一") + void shouldRequireUniqueExpressCompanyCodeWithinTenant() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); when(repository.existsCode(10001L, "SF", null)).thenReturn(true); ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); @@ -123,7 +133,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司并发重复编码统一返回业务冲突() { + @DisplayName("快递公司并发重复编码统一返回业务冲突") + void shouldReturnBusinessConflictForConcurrentDuplicateExpressCompanyCode() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any()); ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository); @@ -133,7 +144,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司新增写入租户和请求字段() { + @DisplayName("快递公司新增写入租户和请求字段") + void shouldPersistTenantAndRequestFieldsWhenCreatingExpressCompany() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); doAnswer(invocation -> { ExpressCompanyEntity entity = invocation.getArgument(0); @@ -153,7 +165,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司编辑保留归属并更新字段() { + @DisplayName("快递公司编辑保留归属并更新字段") + void shouldPreserveOwnershipAndUpdateFieldsWhenEditingExpressCompany() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); ExpressCompanyEntity entity = new ExpressCompanyEntity(); entity.setId(29L); @@ -169,7 +182,8 @@ class PlatformMasterDataManagementServiceTest { } @Test - void 快递公司停用保留主数据() { + @DisplayName("快递公司停用保留主数据") + void shouldPreserveExpressCompanyMasterDataWhenDeactivating() { ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class); ExpressCompanyEntity entity = new ExpressCompanyEntity(); entity.setId(9L); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 7ca3c87fe..9824f18fc 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -3,14 +3,17 @@ package com.baiyue.warehouse.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.repository.WarehouseRepository; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +@DisplayName("仓库应用服务") class WarehouseApplicationServiceTest { @Test - void 商户详情只返回启用且属于租户的仓库() { + @DisplayName("商户详情只返回启用且属于租户的仓库") + void shouldReturnOnlyActiveTenantOwnedWarehouseForMerchantDetail() { WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseEntity warehouse=new WarehouseEntity(); warehouse.setId(1L); warehouse.setTenantId(10001L); warehouse.setStatus("ACTIVE"); when(repository.findActiveOwned(10001,1)).thenReturn(warehouse); @@ -21,7 +24,8 @@ class WarehouseApplicationServiceTest { verify(repository).findActiveOwned(10001,1); } @Test - void 停用或跨租户仓库按不存在处理() { + @DisplayName("停用或跨租户仓库按不存在处理") + void shouldTreatInactiveOrCrossTenantWarehouseAsNotFound() { WarehouseRepository repository=mock(WarehouseRepository.class); WarehouseApplicationService service=new WarehouseApplicationService(); ReflectionTestUtils.setField(service,"repository",repository); assertThatThrownBy(() -> service.activeDetail(10002,1)).isInstanceOf(BizException.class) diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java new file mode 100644 index 000000000..df509c781 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java @@ -0,0 +1,43 @@ +package com.baiyue.warehouse.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.BadSqlGrammarException; + +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@DisplayName("仓库表结构就绪检查服务") +class WarehouseSchemaReadinessServiceTest { + @Test + @DisplayName("全部表字段可查询时返回就绪") + void shouldReportUpWhenAllTablesAndColumnsAreQueryable() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + + var readiness = new WarehouseSchemaReadinessService(jdbcTemplate).readiness(); + + assertThat(readiness.ready()).isTrue(); + assertThat(readiness.status()).isEqualTo("UP"); + verify(jdbcTemplate).execute(org.mockito.ArgumentMatchers.contains("wh_express_company")); + } + + @Test + @DisplayName("缺表或缺列时返回降级而不是伪报正常") + void shouldReportDegradedWhenTableOrColumnIsMissing() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + doThrow(new BadSqlGrammarException("probe", "SELECT", new SQLException("missing"))) + .when(jdbcTemplate).execute(anyString()); + + var readiness = new WarehouseSchemaReadinessService(jdbcTemplate).readiness(); + + assertThat(readiness.ready()).isFalse(); + assertThat(readiness.status()).isEqualTo("DEGRADED"); + assertThat(readiness.message()).contains("迁移"); + } +}