暂存提交
This commit is contained in:
@@ -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 <module> -am test`。
|
||||
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
|
||||
|
||||
@@ -45,4 +47,5 @@
|
||||
- Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。
|
||||
- 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。
|
||||
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
|
||||
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。
|
||||
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
|
||||
|
||||
@@ -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("新名称"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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\"]}");
|
||||
|
||||
|
||||
@@ -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 序列化保持一致。
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<dependencies>
|
||||
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><optional>true</optional></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
+18
@@ -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")
|
||||
|
||||
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
+2
@@ -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(); }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+2
@@ -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() {
|
||||
|
||||
@@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -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 产生精度截断。
|
||||
*
|
||||
* <p>请求反序列化仍兼容 JSON 字符串和安全范围内的 JSON 数字;数据库与领域模型继续使用
|
||||
* {@code BIGINT}/{@code Long},本配置只改变 HTTP JSON 表达。</p>
|
||||
*/
|
||||
@AutoConfiguration
|
||||
public class LongIdJacksonConfiguration {
|
||||
|
||||
@Bean
|
||||
Jackson2ObjectMapperBuilderCustomizer longAsStringJacksonCustomizer() {
|
||||
return builder -> builder
|
||||
.serializerByType(Long.class, ToStringSerializer.instance)
|
||||
.serializerByType(Long.TYPE, ToStringSerializer.instance);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
com.baiyue.common.web.LongIdJacksonConfiguration
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 数据库。
|
||||
@@ -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`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。
|
||||
|
||||
@@ -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恢复原元数据并删除本次文件;若样本通过正常上传审核流程产生,应由测试数据清理流程按同一组标识回收,禁止直接删除仍被资质元数据引用的文件。
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+7
-5
@@ -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"));
|
||||
|
||||
|
||||
@@ -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 上传接口。
|
||||
|
||||
@@ -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<String> 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, "企业资质文件存储暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,为任务列表、按订单查询和分配历史分页补齐索引。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||
<artifactId>firstleg-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
|
||||
|
||||
+8
-3
@@ -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)
|
||||
|
||||
@@ -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<FulfillmentTaskEntity> 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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+5
-1
@@ -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 回归。
|
||||
|
||||
@@ -25,18 +25,17 @@ import java.util.Set;
|
||||
* 因此角色被停用或权限被回收后,下一次查询会立即生效。</p>
|
||||
*/
|
||||
@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<Set<String>> 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, "不支持的应用端");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TenantResponse> 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<TenantResponse> 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<TenantResponse> 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 / 查询成功"),
|
||||
|
||||
@@ -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<InternalUserResponse> 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 / 查询成功")})
|
||||
|
||||
@@ -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<IamPermission> 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<IamPermission> 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<Void> 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')")
|
||||
|
||||
@@ -44,6 +44,9 @@ public final class PlatformInternalUserDtos {
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||
String newPassword) { }
|
||||
|
||||
/** 全量替换平台成员角色;空集合表示清空全部自定义角色。 */
|
||||
public record AssignInternalUserRolesRequest(@NotNull List<Long> roleIds) { }
|
||||
|
||||
public record InternalUserResponse(
|
||||
Long membershipId,
|
||||
Long userId,
|
||||
|
||||
@@ -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<Long> roleIds) { }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<IamPermission>()
|
||||
.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<IamPermission>()
|
||||
.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<IamRolePermission>()
|
||||
.eq(IamRolePermission::getTenantId, tenantId)
|
||||
.eq(IamRolePermission::getPermissionId, permissionId)) > 0;
|
||||
}
|
||||
|
||||
public void deletePermission(IamPermission permission) { permissionMapper.deleteById(permission.getId()); }
|
||||
|
||||
public List<IamRole> findRoles(long tenantId, String appCode) {
|
||||
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
|
||||
|
||||
@@ -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<Long> 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<Long> roleIds) {
|
||||
IamUser existingUser = repository.findUserByAuthId(request.authUserId());
|
||||
IamMembership existingMembership = repository.findByTenantAndAuthUser(
|
||||
|
||||
@@ -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<Long> 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())) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 '逻辑删除标记',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
|
||||
@@ -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'}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')",
|
||||
|
||||
+11
-1
@@ -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 extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
|
||||
return method.getParameters()[parameter].getAnnotation(type);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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() {
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
@@ -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`;后者包含幂等表创建,必须在部署订单写接口前完成。
|
||||
|
||||
@@ -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<Long> 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<Void> 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<Void> 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 / 查询成功"),
|
||||
|
||||
@@ -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 下单资格响应无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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='订单写接口持久化幂等记录';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<OrderIdempotencyRecordEntity> 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);
|
||||
|
||||
@@ -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("解码失败"));
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+5
-1
@@ -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`。
|
||||
|
||||
+31
@@ -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<ApiResponse<Void>> 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));
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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<Map<String, String>> status() {
|
||||
return ApiResponse.success(Map.of("service", "warehouse-service", "status", "UP"));
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+40
@@ -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<String> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
+6
-2
@@ -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");
|
||||
|
||||
+30
@@ -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("数据库");
|
||||
}
|
||||
}
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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));
|
||||
|
||||
|
||||
@@ -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<LambdaQueryWrapper<PartnerEntity>> 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<LambdaQueryWrapper<PartnerEntity>> 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));
|
||||
|
||||
|
||||
+26
-3
@@ -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));
|
||||
}
|
||||
|
||||
+26
-12
@@ -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);
|
||||
|
||||
+6
-2
@@ -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)
|
||||
|
||||
+43
@@ -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("迁移");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user