From 704cb15ccf17ffa45546f1ea6e00a9908ff15fa1 Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Fri, 31 Jul 2026 16:53:07 +0800
Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 11 +-
.../service/AccountManagementServiceTest.java | 14 +-
.../auth/service/TokenSessionStoreTest.java | 2 +
.../baiyue/common/core/ApiResponseTest.java | 2 +
.../common/event/EventEnvelopeTest.java | 14 +-
common-openapi/AGENTS.md | 2 +
common-openapi/pom.xml | 1 +
.../BaiyueOpenApiAutoConfiguration.java | 18 ++
.../LongAsStringModelConverterTest.java | 29 +++
.../security/JwtAuthenticationFilterTest.java | 2 +
.../common/security/JwtClaimsMapperTest.java | 2 +
.../RedisAccessTokenSessionValidatorTest.java | 2 +
common-web/AGENTS.md | 2 +
.../web/LongIdJacksonConfiguration.java | 23 +++
...ot.autoconfigure.AutoConfiguration.imports | 1 +
.../common/web/CorrelationIdFilterTest.java | 2 +
.../web/GlobalExceptionHandlerTest.java | 4 +-
.../web/LongIdJacksonConfigurationTest.java | 57 ++++++
deploy/docker-compose.yml | 15 +-
...026-07-31平台管理与头程履约补齐回归报告.md | 95 ++++++++++
docs/phase1-local-api.md | 2 +
docs/企业资质文件迁移说明.md | 15 ++
.../InternalServiceTokenGuardTest.java | 8 +-
.../service/EventInboxServiceTest.java | 12 +-
file/AGENTS.md | 4 +
.../service/LocalQualificationStorage.java | 33 +++-
file/src/main/resources/application.yml | 2 +-
.../QualificationFileContractTest.java | 2 +
.../LocalQualificationStorageTest.java | 29 +++
firstleg/AGENTS.md | 1 +
firstleg/pom.xml | 1 +
...FulfillmentTaskControllerContractTest.java | 11 +-
.../service/FulfillmentTaskServiceTest.java | 11 +-
.../AppScopeAuthorizationManagerTest.java | 2 +
.../config/GatewayCorsConfigurationTest.java | 2 +
iam/AGENTS.md | 6 +-
.../controller/AuthorizationController.java | 13 +-
.../controller/PlatformTenantController.java | 34 +++-
.../controller/ProfileResourceController.java | 10 ++
.../baiyue/iam/controller/RbacController.java | 30 ++++
.../iam/dto/PlatformInternalUserDtos.java | 3 +
.../java/com/baiyue/iam/dto/RbacDtos.java | 11 ++
.../java/com/baiyue/iam/dto/TenantDtos.java | 15 ++
.../com/baiyue/iam/entity/IamPermission.java | 5 +
.../baiyue/iam/repository/RbacRepository.java | 24 +++
.../service/PlatformInternalUserService.java | 13 ++
.../com/baiyue/iam/service/RbacService.java | 69 +++++++
.../com/baiyue/iam/service/TenantService.java | 43 +++++
iam/src/main/resources/db/iam.sql | 1 +
.../main/resources/db/phase1-admin-rbac.sql | 20 +++
...-master-data-and-permission-management.sql | 52 ++++++
.../resources/db/rbac-role-management-v2.sql | 4 +-
iam/src/main/resources/openapi/iam-v1.yaml | 169 +++++++++++++++++-
.../AuthorizationControllerContractTest.java | 4 +-
.../controller/IamOpenApiContractTest.java | 6 +-
.../controller/MerchantMemberRouteTest.java | 4 +-
.../PlatformInternalUserContractTest.java | 10 +-
.../PlatformTenantControllerContractTest.java | 12 +-
.../QualificationUploadContractTest.java | 2 +
.../AuthorizationSnapshotServiceTest.java | 4 +-
.../baiyue/iam/service/MemberServiceTest.java | 16 +-
.../service/PersonalProfileServiceTest.java | 6 +-
.../PlatformInternalUserServiceTest.java | 42 ++++-
.../baiyue/iam/service/RbacServiceTest.java | 97 ++++++++--
.../baiyue/iam/service/TenantServiceTest.java | 70 ++++++++
.../config/IntegrationPropertiesTest.java | 2 +
.../service/ocr/ImageCropperTest.java | 2 +
.../ocr/WeightCandidateSelectorTest.java | 2 +
.../ocr/WeightOcrBatchServiceTest.java | 2 +
.../ocr/WeightOcrStoragePolicyTest.java | 2 +
order/AGENTS.md | 9 +
.../PlatformFirstLegOrderController.java | 66 +++++++
.../order/service/OrderEligibilityGuard.java | 5 +
order/src/main/resources/db/order.sql | 4 +
.../resources/db/phase1-order-extension.sql | 23 +++
.../src/main/resources/openapi/order-v1.yaml | 63 +++++++
.../OrderControllerPermissionTest.java | 16 +-
.../OrderApplicationServiceStateTest.java | 4 +-
.../service/OrderEligibilityGuardTest.java | 32 ++++
.../service/OrderIdempotencyServiceTest.java | 52 +++++-
.../service/OrderMasterDataGuardTest.java | 12 +-
scripts/migrate-local-qualification-file.ps1 | 86 +++++++++
warehouse/AGENTS.md | 6 +-
.../WarehouseDataAccessExceptionHandler.java | 31 ++++
.../controller/WarehouseHealthController.java | 17 +-
.../warehouse/entity/PartnerEntity.java | 17 ++
.../WarehouseSchemaReadinessService.java | 40 +++++
.../db/phase1-warehouse-schema-migration.sql | 147 +++++++++++++++
.../src/main/resources/db/warehouse-seed.sql | 5 +
...tformMasterDataControllerContractTest.java | 8 +-
...rehouseDataAccessExceptionHandlerTest.java | 30 ++++
.../WarehouseHealthControllerTest.java | 26 +++
.../ExpressCompanyRepositoryTest.java | 7 +-
.../repository/PartnerRepositoryTest.java | 16 +-
.../WarehouseSchemaContractTest.java | 29 ++-
...atformMasterDataManagementServiceTest.java | 38 ++--
.../WarehouseApplicationServiceTest.java | 8 +-
.../WarehouseSchemaReadinessServiceTest.java | 43 +++++
98 files changed, 1944 insertions(+), 134 deletions(-)
create mode 100644 common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java
create mode 100644 common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java
create mode 100644 common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
create mode 100644 common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java
create mode 100644 docs/2026-07-31平台管理与头程履约补齐回归报告.md
create mode 100644 iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql
create mode 100644 order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java
create mode 100644 scripts/migrate-local-qualification-file.ps1
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java
create mode 100644 warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql
create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandlerTest.java
create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/WarehouseHealthControllerTest.java
create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessServiceTest.java
diff --git a/AGENTS.md b/AGENTS.md
index 959c2a9a9..c3d67900d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,9 +12,9 @@
|---|---|---|
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` |
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `api/`、`exception/`、`model/`、`mybatis/` |
-| `common-web/` | 全局异常与请求链路标识 | `GlobalExceptionHandler.java`、`CorrelationIdFilter.java` |
+| `common-web/` | 全局异常、请求链路标识、长整型 JSON 字符串序列化 | `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.java` |
-| `common-openapi/` | SpringDoc 自动配置 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` |
+| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` |
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` |
| `gateway/` | 路由、CORS、入口安全、访问域隔离 | `config/`、`filter/`、`src/main/resources/application*.yml` |
@@ -23,7 +23,7 @@
| `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
| `warehouse/` | 仓库、地址、服务能力、往来单位与快递公司主数据 | `controller/`、`WarehouseApplicationService.java`、`PlatformPartnerQueryService.java`、`ExpressCompanyQueryService.java` |
-| `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;元数据表结构尚未接入 |
+| `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;启动时校验存储目录,磁盘故障返回 `S0503`,元数据表结构尚未接入 |
| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
@@ -36,7 +36,9 @@
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
-- 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`。
+- 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
+- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
+- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl -am test`。
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
@@ -45,4 +47,5 @@
- Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。
- 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
+- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java
index 1180692cb..07dda406a 100644
--- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java
+++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java
@@ -8,6 +8,7 @@ import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
@@ -15,6 +16,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
+@DisplayName("账号管理服务")
class AccountManagementServiceTest {
private final AuthAccountRepository repository = mock(AuthAccountRepository.class);
private final TokenSessionStore sessions = mock(TokenSessionStore.class);
@@ -31,7 +33,7 @@ class AccountManagementServiceTest {
when(repository.findById(10L)).thenReturn(account);
}
- @Test void 修改密码校验旧密码并撤销全部会话() {
+ @Test void changePasswordValidatesCurrentPasswordAndRevokesAllSessions() {
service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2"));
assertTrue(encoder.matches("NewPass2", account.getPasswordHash()));
assertEquals(3, account.getTokenVersion());
@@ -39,20 +41,20 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L);
}
- @Test void 原密码错误不得写入() {
+ @Test void changePasswordDoesNotPersistWhenCurrentPasswordIsInvalid() {
assertThrows(BizException.class,
() -> service.changePassword(10L, new ChangePasswordRequest("WrongPass1", "NewPass2")));
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
- @Test void 停用账号撤销全部会话() {
+ @Test void disableAccountRevokesAllSessions() {
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
assertEquals("DISABLED", account.getAccountStatus());
verify(sessions).revokeAll(10L);
}
- @Test void 创建平台账号保存平台应用域且密码仅保存摘要() {
+ @Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() {
when(repository.findById(20L)).thenReturn(null);
when(repository.existsByUsername("platform.user")).thenReturn(false);
@@ -67,7 +69,7 @@ class AccountManagementServiceTest {
assertNotEquals("Password1", captor.getValue().getPasswordHash());
}
- @Test void 相同账号标识和归属重试时幂等返回() {
+ @Test void createAccountRetriesIdempotentlyForSameAccountAndOwner() {
account.setUsername("platform.user"); account.setDisplayName("平台用户");
account.setDefaultTenantId(0L); account.setOperatorTenantId(0L);
account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0);
@@ -79,7 +81,7 @@ class AccountManagementServiceTest {
verify(repository, never()).insert(any());
}
- @Test void 同步显示名撤销已有会话() {
+ @Test void synchronizeDisplayNameRevokesExistingSessions() {
account.setDisplayName("旧名称");
service.updateProfile(10L, new UpdateAccountProfileRequest("新名称"));
diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java
index 8eb6ed0ac..0d53e121b 100644
--- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java
+++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java
@@ -1,6 +1,7 @@
package com.baiyue.auth.service;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
@@ -16,6 +17,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.eq;
+@DisplayName("令牌会话存储")
class TokenSessionStoreTest {
@Test
void storesAccessAndHashedRefreshWithIndependentTtls() {
diff --git a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java
index 1ba26fbea..b4dbca1a2 100644
--- a/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java
+++ b/common-core/src/test/java/com/baiyue/common/core/ApiResponseTest.java
@@ -3,6 +3,7 @@ package com.baiyue.common.core;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -10,6 +11,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+@DisplayName("统一响应对象功能")
class ApiResponseTest {
@Test
void createsSuccessfulResponseWithCorrelationIds() {
diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java
index 8cf5ac3a3..569608b99 100644
--- a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java
+++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java
@@ -3,6 +3,7 @@ package com.baiyue.common.event;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
@@ -12,11 +13,12 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+@DisplayName("跨区域事件信封契约")
class EventEnvelopeTest {
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test
- void 标准Uuid事件可以通过校验并完成序列化往返() throws Exception {
+ void standardUuidEventPassesValidationAndSerializationRoundTrip() throws Exception {
EventEnvelope source = validEnvelope(EventIdentifiers.newUuid());
source.requireValid();
@@ -29,14 +31,14 @@ class EventEnvelopeTest {
}
@Test
- void 合法Ulid可以作为事件标识() {
+ void validUlidCanBeUsedAsEventIdentifier() {
EventEnvelope envelope = validEnvelope("01J8Z4QX9K7M2N3P4R5S6T7V8W");
assertThat(EventEnvelopeValidator.validate(envelope)).isEmpty();
}
@Test
- void 校验器一次返回全部基础错误() {
+ void validatorReturnsAllBasicErrorsAtOnce() {
EventEnvelope envelope = new EventEnvelope(
"bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ",
-1, 0L, " ", " ", "bad-cause", null, "bad-hash");
@@ -51,7 +53,7 @@ class EventEnvelopeTest {
}
@Test
- void 负载摘要可计算并校验() {
+ void payloadDigestCanBeCalculatedAndValidated() {
byte[] payload = "{\"orderId\":\"90001\"}".getBytes(StandardCharsets.UTF_8);
String hash = EventPayloadHash.sha256(payload);
@@ -61,7 +63,7 @@ class EventEnvelopeTest {
}
@Test
- void 对象字段顺序不同仍产生相同规范摘要() throws Exception {
+ void differentObjectFieldOrderProducesSameCanonicalDigest() throws Exception {
JsonNode first = objectMapper.readTree("{\"orderId\":\"90001\",\"route\":{\"country\":\"US\",\"city\":\"LA\"}}");
JsonNode second = objectMapper.readTree("{\"route\":{\"city\":\"LA\",\"country\":\"US\"},\"orderId\":\"90001\"}");
@@ -70,7 +72,7 @@ class EventEnvelopeTest {
}
@Test
- void 数组顺序仍作为业务语义保留() throws Exception {
+ void arrayOrderIsPreservedAsBusinessSemantic() throws Exception {
JsonNode first = objectMapper.readTree("{\"steps\":[\"A\",\"B\"]}");
JsonNode second = objectMapper.readTree("{\"steps\":[\"B\",\"A\"]}");
diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md
index e17a16e06..df55ecf68 100644
--- a/common-openapi/AGENTS.md
+++ b/common-openapi/AGENTS.md
@@ -12,9 +12,11 @@
| 自动配置 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` |
| `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
+| 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` |
## 修改与验证
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
- 从父目录运行 `mvn -pl common-openapi -am test`。
+- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
diff --git a/common-openapi/pom.xml b/common-openapi/pom.xml
index 8ddfe901a..361af6538 100644
--- a/common-openapi/pom.xml
+++ b/common-openapi/pom.xml
@@ -10,5 +10,6 @@
org.springdocspringdoc-openapi-starter-webmvc-ui
org.springframework.bootspring-boot-starter-webtrue
+ org.springframework.bootspring-boot-starter-testtest
diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java
index 04055e3b5..b782e8f27 100644
--- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java
+++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java
@@ -1,5 +1,7 @@
package com.baiyue.common.openapi;
+import com.fasterxml.jackson.databind.type.TypeFactory;
+import io.swagger.v3.core.converter.ModelConverter;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
@@ -43,6 +45,22 @@ public class BaiyueOpenApiAutoConfiguration {
.addParametersItem(header("X-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false));
}
+ /** 保证 OpenAPI 与运行时 JSON 一致:所有 Java 长整型均声明为字符串。 */
+ @Bean
+ ModelConverter longAsStringModelConverter() {
+ return (type, context, chain) -> {
+ if (type.getType() != null) {
+ Class> rawType = TypeFactory.defaultInstance().constructType(type.getType()).getRawClass();
+ if (rawType == Long.class || rawType == Long.TYPE) {
+ return new io.swagger.v3.oas.models.media.StringSchema()
+ .description("长整型以字符串传输,避免浏览器精度丢失")
+ .example("190000000000000001");
+ }
+ }
+ return chain.hasNext() ? chain.next().resolve(type, context, chain) : null;
+ };
+ }
+
@Bean
GroupedOpenApi platformApi(OperationCustomizer tenantHeaderCustomizer) {
return GroupedOpenApi.builder().group("platform")
diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java
new file mode 100644
index 000000000..44a744168
--- /dev/null
+++ b/common-openapi/src/test/java/com/baiyue/common/openapi/LongAsStringModelConverterTest.java
@@ -0,0 +1,29 @@
+package com.baiyue.common.openapi;
+
+import io.swagger.v3.core.converter.AnnotatedType;
+import io.swagger.v3.core.converter.ModelConverter;
+import io.swagger.v3.core.converter.ModelConverterContext;
+import io.swagger.v3.oas.models.media.Schema;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("长整型标识 OpenAPI 序列化功能")
+class LongAsStringModelConverterTest {
+
+ @Test
+ void longShouldBeDocumentedAsString() {
+ ModelConverter converter = new BaiyueOpenApiAutoConfiguration().longAsStringModelConverter();
+
+ Schema> schema = converter.resolve(
+ new AnnotatedType(Long.class),
+ org.mockito.Mockito.mock(ModelConverterContext.class),
+ Collections.emptyIterator());
+
+ assertThat(schema.getType()).isEqualTo("string");
+ assertThat(schema.getExample()).isEqualTo("190000000000000001");
+ }
+}
diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java
index 65967aeac..0f0bd77b9 100644
--- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java
+++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java
@@ -2,6 +2,7 @@ package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockFilterChain;
@@ -16,6 +17,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("JWT 认证过滤功能")
class JwtAuthenticationFilterTest {
@AfterEach
void clearContext() { SecurityContextHolder.clearContext(); }
diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java
index dd5000696..0f47397ab 100644
--- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java
+++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java
@@ -1,5 +1,6 @@
package com.baiyue.common.security;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
@@ -8,6 +9,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("JWT 声明映射功能")
class JwtClaimsMapperTest {
@Test
void mapsTenantUserRolesAndPermissions() {
diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java
index 556bf7426..b8f8b9db2 100644
--- a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java
+++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java
@@ -1,5 +1,6 @@
package com.baiyue.common.security;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -12,6 +13,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+@DisplayName("访问令牌 Redis 会话校验功能")
class RedisAccessTokenSessionValidatorTest {
@Test
void acceptsActiveSession() {
diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md
index 3a60e32d7..c60045455 100644
--- a/common-web/AGENTS.md
+++ b/common-web/AGENTS.md
@@ -14,6 +14,7 @@
| 全局异常映射 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java` |
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
| 异常响应测试 | `src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java` |
+| 长整型 JSON 字符串序列化 | `src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java` |
## 修改与验证
@@ -21,3 +22,4 @@
- 查询参数类型转换失败映射为 400,Bean Validation 约束失败映射为 422,禁止落入通用 500。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
- 从父目录运行 `mvn -pl common-web -am test`。
+- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
diff --git a/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java
new file mode 100644
index 000000000..7cfe445b2
--- /dev/null
+++ b/common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java
@@ -0,0 +1,23 @@
+package com.baiyue.common.web;
+
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * 统一将 Java 长整型序列化为 JSON 字符串,避免浏览器对雪花 ID 产生精度截断。
+ *
+ * 请求反序列化仍兼容 JSON 字符串和安全范围内的 JSON 数字;数据库与领域模型继续使用
+ * {@code BIGINT}/{@code Long},本配置只改变 HTTP JSON 表达。
+ */
+@AutoConfiguration
+public class LongIdJacksonConfiguration {
+
+ @Bean
+ Jackson2ObjectMapperBuilderCustomizer longAsStringJacksonCustomizer() {
+ return builder -> builder
+ .serializerByType(Long.class, ToStringSerializer.instance)
+ .serializerByType(Long.TYPE, ToStringSerializer.instance);
+ }
+}
diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 000000000..013f360a4
--- /dev/null
+++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+com.baiyue.common.web.LongIdJacksonConfiguration
diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java
index ee9683390..08bb2e583 100644
--- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java
+++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java
@@ -1,5 +1,6 @@
package com.baiyue.common.web;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -7,6 +8,7 @@ import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("请求链路标识过滤功能")
class CorrelationIdFilterTest {
private final CorrelationIdFilter filter = new CorrelationIdFilter();
diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java
index f98b3971d..98f7ec0c6 100644
--- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java
+++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java
@@ -2,6 +2,7 @@ package com.baiyue.common.web;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.core.MethodParameter;
@@ -11,6 +12,7 @@ import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("全局异常响应")
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@@ -26,7 +28,7 @@ class GlobalExceptionHandlerTest {
}
@Test
- void 查询参数类型错误映射为四百而不是五百() throws Exception {
+ void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class);
MethodParameter parameter = new MethodParameter(method, 0);
diff --git a/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java
new file mode 100644
index 000000000..d75be7561
--- /dev/null
+++ b/common-web/src/test/java/com/baiyue/common/web/LongIdJacksonConfigurationTest.java
@@ -0,0 +1,57 @@
+package com.baiyue.common.web;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("长整型标识序列化")
+class LongIdJacksonConfigurationTest {
+
+ private final ObjectMapper objectMapper = configuredObjectMapper();
+
+ @Test
+ void longAndPrimitiveLongShouldBeSerializedAsStrings() throws Exception {
+ String json = objectMapper.writeValueAsString(
+ new IdPayload(190000000000000001L, 190000000000000002L));
+
+ assertThat(json).isEqualTo(
+ "{\"boxedId\":\"190000000000000001\",\"primitiveId\":\"190000000000000002\"}");
+ }
+
+ @Test
+ void stringLongShouldStillBeDeserialized() throws Exception {
+ IdPayload payload = objectMapper.readValue(
+ "{\"boxedId\":\"190000000000000001\",\"primitiveId\":\"190000000000000002\"}",
+ IdPayload.class);
+
+ assertThat(payload.boxedId()).isEqualTo(190000000000000001L);
+ assertThat(payload.primitiveId()).isEqualTo(190000000000000002L);
+ }
+
+ @Test
+ void autoConfigurationShouldApplyToSpringObjectMapper() {
+ new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(
+ JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class))
+ .run(context -> {
+ ObjectMapper springObjectMapper = context.getBean(ObjectMapper.class);
+ assertThat(springObjectMapper.writeValueAsString(new IdPayload(1L, 2L)))
+ .isEqualTo("{\"boxedId\":\"1\",\"primitiveId\":\"2\"}");
+ });
+ }
+
+ private ObjectMapper configuredObjectMapper() {
+ Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
+ new LongIdJacksonConfiguration().longAsStringJacksonCustomizer().customize(builder);
+ return builder.build();
+ }
+
+ private record IdPayload(Long boxedId, long primitiveId) {
+ }
+}
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 2ce1fbb07..fb41b5857 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -22,6 +22,7 @@ x-common: &common
REGION_CODE: ${REGION_CODE:-CN}
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
+ LOG_PATH: /app/logs
TZ: Asia/Shanghai
logging:
driver: json-file
@@ -35,6 +36,7 @@ services:
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
ports: ["${GATEWAY_PORT:-9000}:9000"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9000
@@ -47,6 +49,7 @@ services:
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-auth
ports: ["${AUTH_PORT:-9100}:9100"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9100
@@ -59,6 +62,7 @@ services:
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-iam
ports: ["${IAM_PORT:-9201}:9201"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9201
@@ -71,6 +75,7 @@ services:
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-business
ports: ["${FIRSTLEG_PORT:-9202}:9202"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9202
@@ -83,6 +88,7 @@ services:
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-order
ports: ["${ORDER_PORT:-9204}:9204"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9204
@@ -95,6 +101,7 @@ services:
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
ports: ["${WAREHOUSE_PORT:-9205}:9205"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9205
@@ -107,7 +114,9 @@ services:
image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-file
ports: ["${FILE_PORT:-9206}:9206"]
- volumes: ["firstleg-file-data:/data/files"]
+ volumes:
+ - firstleg-file-data:/data/files
+ - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
environment:
<<: *common-environment
SERVER_PORT: 9206
@@ -121,6 +130,7 @@ services:
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-print
ports: ["${PRINT_PORT:-9207}:9207"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9207
@@ -133,6 +143,7 @@ services:
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-integration
ports: ["${INTEGRATION_PORT:-9210}:9210"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9210
@@ -152,6 +163,7 @@ services:
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9300
@@ -164,6 +176,7 @@ services:
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-audit
expose: ["9700"]
+ volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/audit-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9700
diff --git a/docs/2026-07-31平台管理与头程履约补齐回归报告.md b/docs/2026-07-31平台管理与头程履约补齐回归报告.md
new file mode 100644
index 000000000..0337e7c48
--- /dev/null
+++ b/docs/2026-07-31平台管理与头程履约补齐回归报告.md
@@ -0,0 +1,95 @@
+# 平台管理与头程履约补齐回归报告
+
+日期:2026-07-31
+范围:IAM、Warehouse、Order、Firstleg、File 及共享依赖
+边界:只连接本机开发数据库;已执行项目内可重复迁移和可回收联调数据,未连接远程 test 数据库。服务仅使用 `19100/19101/19104/19105/19106` 测试专用端口,回归结束后全部停止,不占用日常 `9000/920x` 端口。
+
+## 一、问题核验结论
+
+前端报告中的后端缺口基本属实:平台客户档案、平台快递公司、平台往来单位、权限资源维护、平台代客订单写入和履约任务查询此前均不完整。平台权限快照虽然在说明中出现,但 Controller 和 OpenAPI 曾发生漂移,导致登录后权限初始化存在失败风险。
+
+本轮已补齐:
+
+- 当前用户有效权限查询,不依赖角色列表权限,按有效成员、有效角色和有效权限实时计算。
+- 平台客户档案分页、详情、编辑、启停,并阻止通过启停接口绕过企业审核。
+- 平台内部用户的成员主键和角色回显,以及新增、编辑、启停、重置密码、角色分配闭环。
+- 权限资源编辑、启停、删除;系统资源、存在子节点或已绑定角色的资源禁止删除。
+- 平台快递公司和往来单位的分页、详情、新增、编辑、启停,所有读写强制携带目标租户。
+- 平台代客创建、编辑、提交头程订单;商户端和平台端创建、提交均消费 `Idempotency-Key`。
+- 订单不存在统一返回 HTTP 404;Warehouse 下游不可用统一映射为受控 503。
+- Firstleg 履约任务列表、详情、按订单查询、分配历史和分页查询。
+- File 企业资质上传改为流式落盘,服务启动时校验存储根目录;跨平台默认目录不再硬编码 Windows 路径,磁盘故障统一返回 `S0503` 并记录底层异常。
+- `Long/long` 由 `common-web` 统一序列化为 JSON 字符串,请求继续接受字符串 ID;`common-openapi` 同步把长整型声明为字符串,避免 19 位雪花 ID 被浏览器截断。
+
+## 二、本地数据库迁移与核验
+
+使用各服务 `application-dev.yml` 的本地连接配置,先读取 `information_schema`,再按第四节顺序执行可重复迁移,最后重新核验关键字段和索引。MySQL 版本为 `8.4.11`。
+
+| 数据库 | 关键表结果 | 当前数据量 |
+|---|---|---|
+| `baiyue_iam` | 成员、角色、权限及关联表均存在;`iam_permission.built_in` 已迁移 | 成员 22、角色 15、权限 115、租户 17 |
+| `baiyue_warehouse` | 仓库、地址、能力、往来单位、快递公司表及必需字段均存在 | 仓库 1、地址 1、能力 1、往来单位 1、启用快递公司 1 |
+| `baiyue_order` | 订单快照字段、快递字段和幂等记录表均已迁移 | 原有订单 5;回归订单已精确清理 |
+| `baiyue_firstleg` | 履约任务表和分配历史表均存在 | 两表均为 0 |
+
+本地 File 存储目录 `D:\baiyue-firstleg-runtime\uploads` 已存在租户 `10001` 的 UUID 资质图片。使用商户 Token 请求 `/api/merchant/v1/files/qualifications/a1d6caa0-14df-4988-831c-d1a8f5d142ff.png` 返回 HTTP 200、`image/png`、47772 字节。旧 `/phase1-seed/demo-business-license.jpg` 记录状态已是 `SUPERSEDED`,不再作为当前有效资质。
+
+关键索引只读核验结果:订单幂等唯一索引、Firstleg 租户任务索引、往来单位和快递公司的租户编码唯一索引均已存在。
+
+迁移实测发现 MySQL 不支持 `ADD COLUMN IF NOT EXISTS`,IAM 与 Warehouse 增量脚本已改为 `information_schema + 动态 SQL` 的可重复写法,并在本地成功执行。远程测试环境部署时仍必须核对迁移版本;健康检查成功不能证明业务表结构已同步。
+
+## 三、自动化回归结果
+
+为避免多窗口并行构建清理同一 `target`,先安装共享模块快照,再隔离执行四个业务模块的 `clean test`。
+
+| 模块 | 测试数 | 结果 |
+|---|---:|---|
+| 共享模块 | 16 | 通过 |
+| IAM | 58 | 通过 |
+| Order | 18 | 通过 |
+| Firstleg | 6 | 通过 |
+| Warehouse | 28 | 通过 |
+| File | 5 | 通过 |
+| 全仓合计 | 169 | 全部通过,0 失败、0 错误、0 跳过 |
+
+业务模块最终命令:
+
+```powershell
+mvn -o "-Dmaven.repo.local=C:\Users\25137\.m2\repository" clean test
+```
+
+最终结果:19 个 Reactor 模块全部构建成功,169 项测试无失败、无错误、无跳过;`git diff --check` 通过。
+
+## 四、真实接口回归
+
+本地启动 Auth、IAM、Order、Warehouse、File,统一连接本机数据库和 Redis,并使用测试专用端口完成以下验证:
+
+- 商户端、平台端统一登录均成功;平台权限快照返回 61 项权限,不依赖角色列表权限。
+- 平台客户分页返回 16 条;内部用户返回 `membershipId` 和 `roleIds`,ID 的 JSON 类型为字符串。
+- 商户仓库、往来单位候选、平台仓库、平台往来单位、平台快递公司均返回真实本地数据;19 位仓库 ID 保持 `190000000000000001`,未发生精度截断。
+- 平台往来单位和快递公司均完成新增、编辑、停用、详情回读;测试记录随后按租户和精确 ID 清理。
+- 商户头程订单完成创建草稿、相同 `Idempotency-Key` 重放返回同一订单 ID、提交、相同提交键重放、详情读取和取消;测试订单及子表、Outbox、幂等记录随后精确清理。
+- 不存在订单返回 HTTP 404;Order OpenAPI 已声明必填 `Idempotency-Key`,`OrderEntity.id` 类型为 `string`,平台提交路径正确。
+- 有效资质文件鉴权预览返回 HTTP 200,文件内容与元数据路径一致。
+
+服务启动期间 Nacos 本地配置读取出现一次 `MalformedInputException`,但五个服务均正常启动且健康检查为 `UP`。这是本地 Nacos 配置内容编码问题,不影响本轮源码与数据库结论;应单独把 Nacos YAML 统一保存为 UTF-8。
+
+## 五、SQL 脚本位置与执行顺序
+
+脚本均放在所属服务的 `src/main/resources/db/`,没有放入临时目录,也没有自动写入本地数据库。
+
+1. IAM 已有库:依次执行 `phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`、`phase1-platform-master-data-and-permission-management.sql`。
+2. Warehouse 新库:依次执行 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql`。
+3. Warehouse 已有库:在基础表脚本后执行 `phase1-warehouse-schema-migration.sql`,再按需执行种子脚本。
+4. Order 新库执行 `order.sql`;已有库执行 `phase1-order-extension.sql`。
+5. Firstleg 新库执行 `firstleg.sql`;已有库执行 `phase1-firstleg-task-list-index.sql`。
+
+File 本轮不涉及数据库迁移脚本。非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;Docker 部署保持 `/data/files` 卷挂载。
+
+以上脚本已在本机开发库按顺序成功执行。其他环境仍需按发布流程独立执行并记录结果,不得以本地执行结果代替环境迁移记录。
+
+## 六、仍需环境联调
+
+- 部署后的 Gateway 仍需做浏览器级跨租户 403、停用账号、停用权限即时生效及移动端验收。
+- PENDING/REJECTED 企业、普通成员和自定义角色需要在专用测试环境准备可回收样本,完成五状态浏览器验收。
+- 远程 test 环境需按第五节执行迁移并部署同一构建版本;本轮没有连接或写入远程 test 数据库。
diff --git a/docs/phase1-local-api.md b/docs/phase1-local-api.md
index 59a8de20d..6d2b4dda9 100644
--- a/docs/phase1-local-api.md
+++ b/docs/phase1-local-api.md
@@ -289,6 +289,8 @@ IAM 的商户资质接口只读。旧的 `POST /api/merchant/v1/iam/tenant/quali
往来单位由 `warehouse-service` 承载,表为 `baiyue_warehouse.wh_partner`,不是独立服务或独立数据库。仓库和往来单位查询强制执行租户隔离和停用过滤。
+平台往来单位和快递公司均为目标商户租户下的主数据,因此所有平台管理接口都必须显式传入 `tenantId`,缺失或小于 1 返回参数错误。`/api/platform/v1/warehouses/status` 会实际探测仓库、地址、服务能力、往来单位和快递公司表的必需字段;缺表或缺列返回 HTTP 503 且状态为 `DEGRADED`。业务查询遇到数据源不可用或结构漂移时统一返回 `S0503`,不再用 `S0500` 隐藏迁移问题。
+
部署到既有数据库时,除主建表脚本外还要执行 `firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql`;该脚本可重复执行,用于补齐履约任务租户时间分页索引。
平台履约可通过 `PUT /api/platform/v1/orders/first-leg/{orderId}/express?tenantId={tenantId}` 写入 `expressCompany,expressTrackingNo`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。
diff --git a/docs/企业资质文件迁移说明.md b/docs/企业资质文件迁移说明.md
index bd4e6e3bc..f481b01aa 100644
--- a/docs/企业资质文件迁移说明.md
+++ b/docs/企业资质文件迁移说明.md
@@ -43,6 +43,19 @@ WHERE deleted = 0
5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。
6. 再次通过平台预览接口验收;验收完成前保留旧文件和旧 URL 清单,以便回滚。
+当前 File 服务使用本地或共享文件目录时,可先运行仓库内脚本生成真实文件、前向 SQL 和回滚 SQL:
+
+```powershell
+.\scripts\migrate-local-qualification-file.ps1 `
+ -ImagePath D:\test-data\脱敏营业执照.png `
+ -StorageRoot D:\baiyue-firstleg-runtime\uploads `
+ -TenantId 10001 `
+ -QualificationId 资质ID `
+ -OldDocumentUrl /phase1-seed/demo-business-license.jpg
+```
+
+脚本会校验文件大小、JPG/PNG 文件签名和复制前后 SHA-256,并生成带 `id + tenant_id + 旧 URL` 乐观条件的前向 SQL及回滚 SQL。脚本不会连接数据库;执行人员确认文件已落到 File 服务实际挂载目录后,才可人工执行生成的前向 SQL。若受影响行数不是 1,必须回滚并删除本次新增文件。对象存储环境不使用该脚本,应通过 File 上传接口迁移。
+
数据库更新模板如下,执行时必须替换为经过文件校验的真实值:
```sql
@@ -60,3 +73,5 @@ COMMIT;
## 新环境初始化
初始化脚本不再创建 `/phase1-seed/...` 虚假资质。测试环境需要可下单的商户时,应使用脱敏但真实存在的 JPG/PNG 文件走 File 上传、平台审核完整流程,使租户达到 `ACTIVE` 且资质达到 `APPROVED`。初始化账号、文件和审核结果应作为一套环境数据验收,不得仅靠 SQL 绕过文件保存。
+
+用于联调的样本必须在名称或图片水印中明确标注“仅供测试”,并登记租户 ID、资质 ID、文件 SHA-256 和创建时间。验收结束后按生成的回滚 SQL恢复原元数据并删除本次文件;若样本通过正常上传审核流程产生,应由测试数据清理流程按同一组标识回收,禁止直接删除仍被资质元数据引用的文件。
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
index 1b8dfb9d2..9ad3b1ac5 100644
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
+++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
@@ -1,20 +1,22 @@
package com.baiyue.eventbridge.security;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+@DisplayName("内部服务令牌校验")
class InternalServiceTokenGuardTest {
@Test
- void 正确令牌允许访问() {
+ void validTokenAllowsAccess() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
}
@Test
- void 未配置令牌时默认关闭接收接口() {
+ void unconfiguredTokenDisablesReceiveEndpoint() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
assertThatThrownBy(() -> guard.verify("anything"))
.isInstanceOf(ResponseStatusException.class)
@@ -22,7 +24,7 @@ class InternalServiceTokenGuardTest {
}
@Test
- void 错误令牌被拒绝() {
+ void invalidTokenIsRejected() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(ResponseStatusException.class)
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
index 97fc88360..f2d21e484 100644
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
+++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
@@ -6,6 +6,7 @@ import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
@@ -20,13 +21,14 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+@DisplayName("跨区域事件收件箱")
class EventInboxServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final InboxStore store = mock(InboxStore.class);
private final EventInboxService service = new EventInboxService(store, objectMapper, "OVERSEAS");
@Test
- void 首次事件落库并返回已接收() {
+ void firstEventPersistsAndReturnsReceived() {
EventEnvelope envelope = envelope("sha256:placeholder");
envelope = withHash(envelope);
@@ -35,7 +37,7 @@ class EventInboxServiceTest {
}
@Test
- void 相同事件和摘要重复接收不再次写入() {
+ void identicalEventAndDigestDoesNotPersistAgain() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
BridgeInbox existing = existing(envelope.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
@@ -44,7 +46,7 @@ class EventInboxServiceTest {
}
@Test
- void 相同事件但摘要不同时拒绝覆盖() {
+ void sameEventWithDifferentDigestRejectsOverwrite() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64)));
@@ -54,7 +56,7 @@ class EventInboxServiceTest {
}
@Test
- void 并发插入冲突后按既有记录判定为重复() {
+ void duplicateInsertAfterRaceIsTreatedAsDuplicate() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash()));
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
@@ -63,7 +65,7 @@ class EventInboxServiceTest {
}
@Test
- void 目标区域与当前部署区域不一致时拒绝接收() {
+ void targetRegionDifferentFromCurrentDeploymentIsRejected() {
EventInboxService cnService = new EventInboxService(store, objectMapper, "CN");
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
diff --git a/file/AGENTS.md b/file/AGENTS.md
index 27d1fcaf1..eb87487db 100644
--- a/file/AGENTS.md
+++ b/file/AGENTS.md
@@ -19,6 +19,7 @@
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java` |
| 目标表结构(尚未接入) | `src/main/resources/db/file.sql` |
+| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` |
## 修改与验证
@@ -27,3 +28,6 @@
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
- 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。
- 当前没有专属自动化测试;从父目录运行 `mvn -pl file -am test`,并按接口影响验证上传、预览、无权限和非法文件场景。
+- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而上传返回 500。
+- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
+- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。
diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
index 830394a15..91f37a3e6 100644
--- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
+++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java
@@ -1,6 +1,10 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.core.api.ResultCode;
+import jakarta.annotation.PostConstruct;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
@@ -11,12 +15,15 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.StandardOpenOption;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@Service
public class LocalQualificationStorage {
+ private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class);
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set TYPES = Set.of("image/jpeg", "image/png");
private final Path root;
@@ -25,6 +32,20 @@ public class LocalQualificationStorage {
this.root = Path.of(root).toAbsolutePath().normalize();
}
+ /** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */
+ @PostConstruct
+ public void initialize() {
+ try {
+ Files.createDirectories(root);
+ if (!Files.isDirectory(root) || !Files.isWritable(root)) {
+ throw new IOException("存储根目录不可写");
+ }
+ } catch (IOException exception) {
+ LOGGER.error("企业资质存储根目录不可用:{}", root, exception);
+ throw new IllegalStateException("企业资质存储根目录不可用", exception);
+ }
+ }
+
public StoredFile store(long tenantId, MultipartFile file) {
if (file == null || file.isEmpty()) throw new BizException("One business-license image is required");
if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB");
@@ -40,11 +61,19 @@ public class LocalQualificationStorage {
if (!target.startsWith(directory)) throw new BizException("Invalid upload path");
try {
Files.createDirectories(directory);
- file.transferTo(target);
+ try (InputStream input = file.getInputStream();
+ OutputStream output = Files.newOutputStream(target,
+ StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
+ input.transferTo(output);
+ }
return new StoredFile(target, fileName,
"/api/merchant/v1/files/qualifications/" + fileName);
} catch (IOException exception) {
- throw new BizException("Unable to store enterprise qualification");
+ try { Files.deleteIfExists(target); } catch (IOException cleanupException) {
+ exception.addSuppressed(cleanupException);
+ }
+ LOGGER.error("企业资质文件写入失败,tenantId={},目录={}", tenantId, directory, exception);
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业资质文件存储暂不可用");
}
}
diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml
index 47aaa70f1..a024e7c2c 100644
--- a/file/src/main/resources/application.yml
+++ b/file/src/main/resources/application.yml
@@ -32,5 +32,5 @@ baiyue:
iam:
url: ${IAM_SERVICE_URL:}
file:
- storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads}
+ storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
management.endpoints.web.exposure.include: health,info,prometheus
diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
index 0ce2d6a75..3cb468f2d 100644
--- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
+++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java
@@ -1,5 +1,6 @@
package com.baiyue.file.controller;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
@@ -9,6 +10,7 @@ import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertTrue;
+@DisplayName("企业资质文件接口契约功能")
class QualificationFileContractTest {
@Test
diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java
index 292f503c7..552c7793e 100644
--- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java
+++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java
@@ -1,6 +1,8 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.core.api.ResultCode;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
@@ -12,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+@DisplayName("本地企业资质文件存储功能")
class LocalQualificationStorageTest {
@TempDir
@@ -20,6 +23,7 @@ class LocalQualificationStorageTest {
@Test
void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception {
var storage = new LocalQualificationStorage(storageRoot.toString());
+ storage.initialize();
byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01};
var upload = new MockMultipartFile("file", "license.png", "image/png", png);
@@ -39,4 +43,29 @@ class LocalQualificationStorageTest {
assertThrows(BizException.class,
() -> storage.load(10001L, "demo-business-license.jpg"));
}
+
+ @Test
+ void invalidStorageFailsFastDuringStartup() throws Exception {
+ Path invalidRoot = storageRoot.resolve("root-is-file");
+ Files.writeString(invalidRoot, "not-a-directory");
+ var storage = new LocalQualificationStorage(invalidRoot.toString());
+
+ IllegalStateException exception = assertThrows(IllegalStateException.class, storage::initialize);
+
+ assertTrue(exception.getMessage().contains("存储根目录不可用"));
+ }
+
+ @Test
+ void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception {
+ var storage = new LocalQualificationStorage(storageRoot.toString());
+ storage.initialize();
+ Files.writeString(storageRoot.resolve("qualifications"), "阻止创建租户目录");
+ byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01};
+ var upload = new MockMultipartFile("file", "license.png", "image/png", png);
+
+ BizException exception = assertThrows(BizException.class,
+ () -> storage.store(10001L, upload));
+
+ assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
+ }
}
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index d6dbeb105..0dd49e293 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -28,3 +28,4 @@
- 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。
- 当前模块没有专属自动化测试时,不要为小改动擅自引入新框架;至少运行模块构建并记录接口验证。
- 从父目录运行 `mvn -pl firstleg -am test`。
+- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。
diff --git a/firstleg/pom.xml b/firstleg/pom.xml
index 344b9aca3..467108e07 100644
--- a/firstleg/pom.xml
+++ b/firstleg/pom.xml
@@ -5,6 +5,7 @@
com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml
firstleg-service
+ com.baiyue.sharedbaiyue-common-core${project.version}
com.baiyue.sharedbaiyue-common-audit${project.version}
com.baiyue.sharedbaiyue-common-openapi${project.version}
com.baomidoumybatis-plus-spring-boot3-starter
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java
index 32cd2f2cb..66645eefb 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FulfillmentTaskControllerContractTest.java
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.AssignRequest;
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource;
@@ -12,9 +13,11 @@ import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("履约任务控制器契约")
class FulfillmentTaskControllerContractTest {
@Test
- void 查询和分配接口声明对应权限码() throws Exception {
+ @DisplayName("查询和分配接口声明对应权限码")
+ void shouldDeclarePermissionsForQueryAndAssignmentEndpoints() throws Exception {
assertAuthority("page", "hasAuthority('platform:firstleg:task:list')",
AuthenticatedUser.class, long.class, FulfillmentTaskQuery.class);
assertAuthority("detail", "hasAuthority('platform:firstleg:task:view')",
@@ -28,14 +31,16 @@ class FulfillmentTaskControllerContractTest {
}
@Test
- void 分页查询缺省值为第一页每页二十条() {
+ @DisplayName("分页查询缺省值为第一页每页二十条")
+ void shouldDefaultPageQueryToFirstPageAndTwentyItems() {
FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 0, 0);
assertThat(query.page()).isEqualTo(1);
assertThat(query.size()).isEqualTo(20);
}
@Test
- void OpenAPI覆盖四类只读接口和对应权限() throws Exception {
+ @DisplayName("OpenAPI覆盖四类只读接口和对应权限")
+ void shouldDocumentFourReadOnlyEndpointsInOpenApi() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java
index ce793db13..3a77a1533 100644
--- a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java
+++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
import com.baiyue.firstleg.repository.FulfillmentTaskRepository;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -17,9 +18,11 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+@DisplayName("履约任务服务")
class FulfillmentTaskServiceTest {
@Test
- void 列表查询保留租户和分页条件() {
+ @DisplayName("列表查询保留租户和分页条件")
+ void shouldPreserveTenantAndPaginationInPageQuery() {
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 1, 20);
PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0);
@@ -30,7 +33,8 @@ class FulfillmentTaskServiceTest {
}
@Test
- void 详情和按订单查询不存在时返回404() {
+ @DisplayName("详情和按订单查询不存在时返回404")
+ void shouldReturnNotFoundForMissingTaskAndOrder() {
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
FulfillmentTaskService service = service(repository);
@@ -45,7 +49,8 @@ class FulfillmentTaskServiceTest {
}
@Test
- void 查询分配历史前先校验任务归属租户() {
+ @DisplayName("查询分配历史前先校验任务归属租户")
+ void shouldVerifyTaskTenantBeforeLoadingAssignmentHistory() {
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
FulfillmentTaskEntity task = new FulfillmentTaskEntity();
when(repository.findOwned(10001L, 1L)).thenReturn(task);
diff --git a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
index 21e2371a7..f7b102276 100644
--- a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
+++ b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
@@ -1,5 +1,6 @@
package com.baiyue.gateway.config;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
@@ -9,6 +10,7 @@ import reactor.test.StepVerifier;
import java.time.Instant;
+@DisplayName("网关应用域授权功能")
class AppScopeAuthorizationManagerTest {
@Test
void acceptsMatchingApplicationScope() {
diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java
index a4631a245..142e22997 100644
--- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java
+++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java
@@ -1,5 +1,6 @@
package com.baiyue.gateway.config;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
@@ -17,6 +18,7 @@ import org.springframework.test.web.reactive.server.WebTestClient;
"baiyue.cors.allowed-origins=http://localhost:5173"
})
@AutoConfigureWebTestClient
+@DisplayName("网关跨域配置功能")
class GatewayCorsConfigurationTest {
private static final String PROFILE_URL =
"http://localhost/api/merchant/v1/iam/tenant/profile";
diff --git a/iam/AGENTS.md b/iam/AGENTS.md
index c78c4af09..2e9495867 100644
--- a/iam/AGENTS.md
+++ b/iam/AGENTS.md
@@ -27,6 +27,8 @@
| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料和 RBAC 测试 |
## 修改与验证
+- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
+- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
@@ -34,5 +36,7 @@
- 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
-- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。
+- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
+- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。
+- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
- 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。
diff --git a/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java
index 7fe156f7d..6439cffd3 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/AuthorizationController.java
@@ -25,18 +25,17 @@ import java.util.Set;
* 因此角色被停用或权限被回收后,下一次查询会立即生效。
*/
@RestController
-@Tag(name = "Authorization / 当前用户授权", description = "Returns current member effective permission codes.")
+@Tag(name = "当前用户授权", description = "返回当前成员的有效权限编码。")
public class AuthorizationController {
@Resource
private AuthorizationSnapshotService authorizationSnapshotService;
@GetMapping("/api/{app}/v1/iam/authorization/permissions")
- @Operation(summary = "Get current effective permissions / 查询当前用户有效权限",
- description = "Does not require role-management permissions. / 不依赖角色管理权限。")
+ @Operation(summary = "查询当前用户有效权限", description = "不依赖角色管理权限。")
@ApiResponses({
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Permissions returned"),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required"),
- @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Wrong application scope or inactive identity")
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "权限查询成功"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "应用权限域错误或身份已停用")
})
public ApiResponse> permissions(@PathVariable String app,
@AuthenticationPrincipal AuthenticatedUser user) {
@@ -49,7 +48,7 @@ public class AuthorizationController {
return switch (app) {
case "platform" -> "PLATFORM";
case "merchant" -> "MERCHANT";
- default -> throw new BizException(ResultCode.BAD_REQUEST, "Unsupported application scope / 不支持的应用端");
+ default -> throw new BizException(ResultCode.BAD_REQUEST, "不支持的应用端");
};
}
}
diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
index 0839545b9..1ad414d52 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
@@ -6,6 +6,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
import com.baiyue.iam.dto.TenantDtos.ReviewRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
+import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
+import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService;
@@ -38,7 +40,7 @@ public class PlatformTenantController {
@GetMapping("/tenants")
@PreAuthorize("hasAuthority('platform:customer:list')")
- @Operation(summary = "List enterprise tenants / 查询企业列表", description = "Returns paged merchant enterprise tenants for platform operators. / 平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。")
+ @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"),
@@ -57,6 +59,15 @@ public class PlatformTenantController {
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
}
+ @GetMapping("/tenants/{tenantId:\\d+}")
+ @PreAuthorize("hasAuthority('platform:customer:list')")
+ @Operation(summary = "查询企业客户档案")
+ public ApiResponse tenant(@PathVariable long tenantId,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ TenantGuard.tenant(user, "PLATFORM");
+ return ApiResponse.success(service.merchant(tenantId));
+ }
+
@PostMapping("/tenants")
@PreAuthorize("hasAuthority('platform:customer:create')")
@Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。")
@@ -72,7 +83,28 @@ public class PlatformTenantController {
return ApiResponse.success(service.register(request));
}
+ @PutMapping("/tenants/{tenantId:\\d+}")
+ @PreAuthorize("hasAuthority('platform:customer:edit')")
+ @Operation(summary = "编辑企业客户档案")
+ public ApiResponse updateTenant(@PathVariable long tenantId,
+ @Valid @RequestBody UpdateTenantRequest request,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ TenantGuard.tenant(user, "PLATFORM");
+ return ApiResponse.success(service.updateMerchant(tenantId, request));
+ }
+
+ @PutMapping("/tenants/{tenantId:\\d+}/status")
+ @PreAuthorize("hasAuthority('platform:customer:status')")
+ @Operation(summary = "启停企业客户")
+ public ApiResponse changeTenantStatus(@PathVariable long tenantId,
+ @Valid @RequestBody ChangeTenantStatusRequest request,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ TenantGuard.tenant(user, "PLATFORM");
+ return ApiResponse.success(service.changeMerchantStatus(tenantId, request));
+ }
+
@GetMapping("/reviews")
+ @PreAuthorize("hasAuthority('platform:customer:review')")
@Operation(summary = "List pending enterprise reviews / 查询待审核企业", description = "Return pending enterprise registration review tickets ordered by creation. / 按创建顺序返回待审核企业注册单。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"),
diff --git a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java
index bf8fc3be0..d4d8f77c3 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/ProfileResourceController.java
@@ -81,6 +81,16 @@ public class ProfileResourceController {
return ApiResponse.success(null);
}
+ @PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles")
+ @PreAuthorize("hasAuthority('platform:user:grant-role')")
+ @Operation(summary = "分配平台内部用户角色")
+ public ApiResponse assignInternalUserRoles(
+ @PathVariable Long membershipId, @Valid @RequestBody AssignInternalUserRolesRequest request,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ TenantGuard.tenant(user, "PLATFORM");
+ return ApiResponse.success(internalUserService.assignRoles(membershipId, request));
+ }
+
@GetMapping("/api/merchant/v1/iam/tenant/addresses")
@Operation(summary = "List enterprise addresses / 查询企业地址", description = "Tenant ID is taken from the merchant JWT. / 租户 ID 从商户 JWT 获取。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address list returned / 查询成功")})
diff --git a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java
index 29b315105..c2056cee6 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/RbacController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/RbacController.java
@@ -122,6 +122,36 @@ public class RbacController {
return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request));
}
+ @PutMapping("/permissions/{permissionId}")
+ @Operation(summary = "编辑自定义权限资源")
+ @PreAuthorize("hasAuthority(#app + ':permission:edit')")
+ public ApiResponse updatePermission(@PathVariable String app, @PathVariable Long permissionId,
+ @Valid @RequestBody UpdatePermissionRequest request,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ Scope scope = scope(app, user);
+ return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request));
+ }
+
+ @PutMapping("/permissions/{permissionId}/status")
+ @Operation(summary = "启停自定义权限资源")
+ @PreAuthorize("hasAuthority(#app + ':permission:status')")
+ public ApiResponse changePermissionStatus(@PathVariable String app, @PathVariable Long permissionId,
+ @Valid @RequestBody ChangePermissionStatusRequest request,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ Scope scope = scope(app, user);
+ return ApiResponse.success(service.changePermissionStatus(scope.tenantId(), scope.appCode(), permissionId, request));
+ }
+
+ @DeleteMapping("/permissions/{permissionId}")
+ @Operation(summary = "删除未绑定的自定义权限资源")
+ @PreAuthorize("hasAuthority(#app + ':permission:delete')")
+ public ApiResponse deletePermission(@PathVariable String app, @PathVariable Long permissionId,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ Scope scope = scope(app, user);
+ service.deletePermission(scope.tenantId(), scope.appCode(), permissionId);
+ return ApiResponse.success(null);
+ }
+
@PutMapping("/roles/permissions")
@Operation(summary = "全量设置角色权限,空集合表示清空")
@PreAuthorize("hasAuthority(#app + ':role:grant')")
diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java
index 10becd5e9..5e36682fa 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java
@@ -44,6 +44,9 @@ public final class PlatformInternalUserDtos {
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
+ /** 全量替换平台成员角色;空集合表示清空全部自定义角色。 */
+ public record AssignInternalUserRolesRequest(@NotNull List roleIds) { }
+
public record InternalUserResponse(
Long membershipId,
Long userId,
diff --git a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java
index a10dafd27..c6719b793 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/RbacDtos.java
@@ -38,6 +38,17 @@ public final class RbacDtos {
String httpMethod,
Integer sortNo) { }
+ /** 权限编码和类型参与服务端鉴权,编辑时不可变更。 */
+ public record UpdatePermissionRequest(
+ @NotBlank String name,
+ Long parentId,
+ String routePath,
+ String httpMethod,
+ Integer sortNo) { }
+
+ public record ChangePermissionStatusRequest(
+ @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { }
+
public record AssignRolesRequest(@NotNull Long membershipId,
@NotNull Set roleIds) { }
diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java
index f424ac26a..fa7ef44a8 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java
@@ -30,6 +30,21 @@ public final class TenantDtos {
@Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail
) { }
+ /** 平台端客户档案维护。统一社会信用代码是注册标识,不允许经由普通编辑接口修改。 */
+ @Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile")
+ public record UpdateTenantRequest(
+ @NotBlank String tenantName,
+ @NotBlank String legalName,
+ @NotBlank String contactName,
+ @NotBlank String contactPhone,
+ @Email String contactEmail
+ ) { }
+
+ @Schema(name = "TenantStatusChangeRequest", description = "Platform controlled merchant tenant status")
+ public record ChangeTenantStatusRequest(
+ @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status
+ ) { }
+
@Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见")
public record ReviewRequest(
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java
index a52de5219..13a4f7571 100644
--- a/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java
+++ b/iam/src/main/java/com/baiyue/iam/entity/IamPermission.java
@@ -4,6 +4,7 @@ import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
/**
@@ -25,6 +26,10 @@ public class IamPermission {
private String httpMethod;
private Integer sortNo;
private String status;
+ /** 系统预置资源不得通过租户权限管理接口修改。 */
+ private Boolean builtIn;
+ @TableLogic
+ private Integer deleted;
diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java
index 155d08a17..3222ba497 100644
--- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java
+++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java
@@ -46,6 +46,30 @@ public class RbacRepository {
public void insertRole(IamRole role) { roleMapper.insert(role); }
public void updateRole(IamRole role) { roleMapper.updateById(role); }
public void insertPermission(IamPermission permission) { permissionMapper.insert(permission); }
+ public void updatePermission(IamPermission permission) { permissionMapper.updateById(permission); }
+
+ /** 写操作必须精确匹配所属权限域;全局模板可读,但商户不可修改。 */
+ public IamPermission findOwnedPermission(Long id, long tenantId, String appCode) {
+ return permissionMapper.selectOne(new LambdaQueryWrapper()
+ .eq(IamPermission::getId, id)
+ .eq(IamPermission::getTenantId, tenantId)
+ .eq(IamPermission::getAppCode, appCode));
+ }
+
+ public boolean hasPermissionChildren(long tenantId, String appCode, Long permissionId) {
+ return permissionMapper.selectCount(new LambdaQueryWrapper()
+ .eq(IamPermission::getTenantId, tenantId)
+ .eq(IamPermission::getAppCode, appCode)
+ .eq(IamPermission::getParentId, permissionId)) > 0;
+ }
+
+ public boolean isPermissionGranted(long tenantId, Long permissionId) {
+ return rolePermissionMapper.selectCount(new LambdaQueryWrapper()
+ .eq(IamRolePermission::getTenantId, tenantId)
+ .eq(IamRolePermission::getPermissionId, permissionId)) > 0;
+ }
+
+ public void deletePermission(IamPermission permission) { permissionMapper.deleteById(permission.getId()); }
public List findRoles(long tenantId, String appCode) {
return roleMapper.selectList(new LambdaQueryWrapper()
diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java
index 3185e6085..4ecc46595 100644
--- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java
@@ -155,6 +155,19 @@ public class PlatformInternalUserService {
new AuthAccountClient.PasswordRequest(request.newPassword())));
}
+ @Transactional
+ @BusinessAudit(event = "PLATFORM_INTERNAL_USER_ROLE_ASSIGN", resourceType = "MEMBERSHIP",
+ resourceId = "#membershipId", description = "分配平台内部用户角色", captureAfter = false)
+ public InternalUserResponse assignRoles(Long membershipId, AssignInternalUserRolesRequest request) {
+ IamMembership membership = required(membershipId);
+ List roleIds = normalizeRoles(request.roleIds());
+ validateRoles(roleIds);
+ repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
+ IamUser user = requiredUser(membership.getUserId());
+ AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
+ return response(membership, user, authAccount.username(), roleIds);
+ }
+
private InternalUserResponse linkExistingAccount(CreateInternalUserRequest request, List roleIds) {
IamUser existingUser = repository.findUserByAuthId(request.authUserId());
IamMembership existingMembership = repository.findByTenantAndAuthUser(
diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java
index e991e1aeb..65c8c8d8b 100644
--- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java
@@ -131,6 +131,7 @@ public class RbacService {
permission.setHttpMethod(request.httpMethod());
permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo());
permission.setStatus("ACTIVE");
+ permission.setBuiltIn(false);
try {
repository.insertPermission(permission);
} catch (DataIntegrityViolationException exception) {
@@ -139,6 +140,46 @@ public class RbacService {
return permission;
}
+ @BusinessAudit(event = "PERMISSION_UPDATE", resourceType = "PERMISSION", resourceId = "#permissionId",
+ description = "编辑权限资源", captureBefore = true, captureAfter = true)
+ public IamPermission updatePermission(long tenantId, String appCode, Long permissionId,
+ UpdatePermissionRequest request) {
+ IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
+ validatePermissionParent(tenantId, appCode, permissionId, request.parentId());
+ permission.setPermissionName(request.name());
+ permission.setParentId(request.parentId() == null ? 0L : request.parentId());
+ permission.setRoutePath(request.routePath());
+ permission.setHttpMethod(request.httpMethod());
+ permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo());
+ repository.updatePermission(permission);
+ return permission;
+ }
+
+ @BusinessAudit(event = "PERMISSION_STATUS_CHANGE", resourceType = "PERMISSION", resourceId = "#permissionId",
+ description = "启停权限资源", captureBefore = true, captureAfter = true)
+ public IamPermission changePermissionStatus(long tenantId, String appCode, Long permissionId,
+ ChangePermissionStatusRequest request) {
+ IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
+ permission.setStatus(request.status());
+ repository.updatePermission(permission);
+ return permission;
+ }
+
+ /** 带有子资源或已授权给角色的资源不得直接删除。 */
+ @BusinessAudit(event = "PERMISSION_DELETE", resourceType = "PERMISSION", resourceId = "#permissionId",
+ description = "删除权限资源", captureBefore = true, captureAfter = false)
+ @Transactional
+ public void deletePermission(long tenantId, String appCode, Long permissionId) {
+ IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
+ if (repository.hasPermissionChildren(tenantId, appCode, permissionId)) {
+ throw new BizException(ResultCode.CONFLICT, "Permission has child resources / 权限资源存在子节点,不能删除");
+ }
+ if (repository.isPermissionGranted(tenantId, permissionId)) {
+ throw new BizException(ResultCode.CONFLICT, "Permission is granted to roles / 权限资源已被角色授予,不能删除");
+ }
+ repository.deletePermission(permission);
+ }
+
public MemberRolesResponse memberRoles(long tenantId, String appCode, Long membershipId) {
requireMembership(tenantId, membershipId);
Set scopedIds = new LinkedHashSet<>();
@@ -191,6 +232,34 @@ public class RbacService {
return role;
}
+ private IamPermission requireMutablePermission(long tenantId, String appCode, Long permissionId) {
+ IamPermission permission = repository.findOwnedPermission(permissionId, tenantId, appCode);
+ if (permission == null) throw forbidden();
+ if (Boolean.TRUE.equals(permission.getBuiltIn())) {
+ throw new BizException(ResultCode.CONFLICT,
+ "Built-in permission cannot be changed / 系统预置权限不允许编辑、启停或删除");
+ }
+ return permission;
+ }
+
+ private void validatePermissionParent(long tenantId, String appCode, Long permissionId, Long parentId) {
+ if (parentId == null || parentId == 0) return;
+ if (parentId.equals(permissionId)) {
+ throw new BizException(ResultCode.BAD_REQUEST, "Permission cannot be its own parent / 权限不能设置自身为父节点");
+ }
+ IamPermission parent = repository.findPermission(parentId, tenantId, appCode);
+ if (parent == null) throw forbidden();
+ Long cursor = parent.getParentId();
+ while (cursor != null && cursor > 0) {
+ if (cursor.equals(permissionId)) {
+ throw new BizException(ResultCode.BAD_REQUEST, "Permission parent cycle is not allowed / 权限节点不能形成环");
+ }
+ IamPermission ancestor = repository.findPermission(cursor, tenantId, appCode);
+ if (ancestor == null || cursor.equals(ancestor.getParentId())) break;
+ cursor = ancestor.getParentId();
+ }
+ }
+
private IamRole requireCustomRole(long tenantId, String appCode, Long roleId) {
IamRole role = requireRole(tenantId, appCode, roleId);
if ("SYSTEM".equals(role.getRoleType())) {
diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
index ebf5739fc..611ab1687 100644
--- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
@@ -14,6 +14,8 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
+import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
+import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
@@ -145,6 +147,39 @@ public class TenantService {
return response(tenant);
}
+ /** 平台端查询客户档案:仅能查商户租户,不暴露平台租户。 */
+ public TenantResponse merchant(long tenantId) {
+ return response(requireMerchant(tenantId));
+ }
+
+ @Transactional
+ @BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId",
+ description = "平台编辑企业客户档案")
+ public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
+ IamTenant tenant = requireMerchant(tenantId);
+ tenant.setTenantName(request.tenantName());
+ tenant.setLegalName(request.legalName());
+ tenant.setContactName(request.contactName());
+ tenant.setContactPhone(request.contactPhone());
+ tenant.setContactEmail(request.contactEmail());
+ repository.saveTenant(tenant);
+ return response(tenant);
+ }
+
+ @Transactional
+ @BusinessAudit(event = "PLATFORM_TENANT_STATUS_CHANGE", resourceType = "TENANT", resourceId = "#tenantId",
+ description = "平台启停企业客户")
+ public TenantResponse changeMerchantStatus(long tenantId, ChangeTenantStatusRequest request) {
+ IamTenant tenant = requireMerchant(tenantId);
+ // PENDING/REJECTED 必须走审核流程,不允许状态维护接口绕过审核。
+ if (!"ACTIVE".equals(tenant.getStatus()) && !"DISABLED".equals(tenant.getStatus())) {
+ throw new BizException(ResultCode.CONFLICT, "Tenant status must be changed through review / 当前租户状态必须通过审核流程变更");
+ }
+ tenant.setStatus(request.status());
+ repository.saveTenant(tenant);
+ return response(tenant);
+ }
+
@Transactional
@BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT",
resourceId = "#tenantId", description = "编辑企业资料")
@@ -166,6 +201,14 @@ public class TenantService {
return tenant.getId() != null && tenant.getId().longValue() == id;
}
+ private IamTenant requireMerchant(long tenantId) {
+ IamTenant tenant = repository.findTenant(tenantId);
+ if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
+ throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在");
+ }
+ return tenant;
+ }
+
private TenantResponse response(IamTenant tenant) {
return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql
index 21a89a701..d132c8573 100644
--- a/iam/src/main/resources/db/iam.sql
+++ b/iam/src/main/resources/db/iam.sql
@@ -158,6 +158,7 @@ CREATE TABLE IF NOT EXISTS iam_permission (
http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态',
+ built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置权限不可通过管理接口修改',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql
index 19fc30c2a..8f832adb1 100644
--- a/iam/src/main/resources/db/phase1-admin-rbac.sql
+++ b/iam/src/main/resources/db/phase1-admin-rbac.sql
@@ -121,6 +121,26 @@ ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
+-- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。
+INSERT INTO iam_permission
+ (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
+VALUES
+ (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
+ (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
+ (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0)
+ON DUPLICATE KEY UPDATE
+ permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0;
+
+INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
+SELECT 4000001, id, 0 FROM iam_permission
+WHERE id IN (5102019, 5102024, 5102025)
+ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+
+INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
+SELECT 4000003, id, 0 FROM iam_permission
+WHERE id IN (5102019, 5102024, 5102025)
+ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+
-- Merchant: level-one menus, level-two menus and buttons.
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
diff --git a/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql
new file mode 100644
index 000000000..2a76408f6
--- /dev/null
+++ b/iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql
@@ -0,0 +1,52 @@
+-- 权限资源维护与平台主数据权限迁移。
+-- 在 phase1-admin-rbac.sql、rbac-role-management-v2.sql 之后执行,可重复执行。
+USE baiyue_iam;
+
+-- 兼容不支持 ADD COLUMN IF NOT EXISTS 的 MySQL 版本,通过元数据判断后执行。
+SET @iam_permission_built_in_sql = IF(
+ EXISTS(SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'iam_permission' AND column_name = 'built_in'),
+ 'SELECT 1',
+ "ALTER TABLE iam_permission ADD COLUMN built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置资源不可通过管理接口修改' AFTER status"
+);
+PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql;
+EXECUTE iam_permission_built_in_stmt;
+DEALLOCATE PREPARE iam_permission_built_in_stmt;
+
+-- 一期固定编号范围内的资源均为种子资源,运行期生成的雪花编号不作预置标记。
+UPDATE iam_permission
+SET built_in = 1
+WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0;
+
+INSERT INTO iam_permission
+ (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
+ sort_no, status, built_in, deleted)
+VALUES
+ (5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0),
+ (5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0),
+ (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0),
+ (5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0),
+ (5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0),
+ (5101034, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:delete', '删除权限资源', 40, 'ACTIVE', 1, 0),
+ (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', 10, 'ACTIVE', 1, 0),
+ (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', 20, 'ACTIVE', 1, 0),
+ (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', 30, 'ACTIVE', 1, 0),
+ (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0),
+ (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0),
+ (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0)
+ON DUPLICATE KEY UPDATE
+ permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE',
+ built_in = 1, deleted = 0;
+
+-- 新增平台操作权限默认只授予系统管理员。
+INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
+SELECT 4000001, id, 0
+FROM iam_permission
+WHERE tenant_id = 0 AND app_code = 'PLATFORM'
+ AND permission_code IN (
+ 'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role',
+ 'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete',
+ 'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status',
+ 'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status'
+ )
+ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql
index 125ec6108..dbdacdd99 100644
--- a/iam/src/main/resources/db/rbac-role-management-v2.sql
+++ b/iam/src/main/resources/db/rbac-role-management-v2.sql
@@ -15,6 +15,7 @@ VALUES
(5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', 20, 'ACTIVE', 0),
(5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0),
(5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0),
+ (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0),
(5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0),
(5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0),
(5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0),
@@ -32,7 +33,8 @@ SELECT 4000001, id, 0 FROM iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete',
'platform:member:list', 'platform:member:grant-role', 'platform:customer:create',
- 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password')
+ 'platform:user:edit', 'platform:user:status', 'platform:user:reset-password',
+ 'platform:user:grant-role')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml
index 5147f5d0c..d12ba99f6 100644
--- a/iam/src/main/resources/openapi/iam-v1.yaml
+++ b/iam/src/main/resources/openapi/iam-v1.yaml
@@ -20,8 +20,8 @@ paths:
/api/platform/v1/iam/tenants:
get:
tags: [Platform Tenant / 平台租户]
- summary: List enterprise tenants / 查询企业客户列表
- description: Returns paged merchant tenants. Requires `platform:customer:list`. / 分页查询商户企业客户,需要 `platform:customer:list` 权限。
+ summary: 查询企业客户列表
+ description: 分页查询商户企业客户,需要 `platform:customer:list` 权限。
parameters:
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
@@ -54,8 +54,8 @@ paths:
/api/platform/v1/iam/authorization/permissions:
get:
tags: [IAM Resources / IAM 资源]
- summary: Get current effective permissions / 查询当前用户有效权限
- description: Returns the current active platform membership's permission codes. It only requires a valid PLATFORM JWT and does not require `platform:role:list`. / 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。
+ summary: 查询当前用户有效权限
+ description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。
responses:
'200':
description: 当前平台成员权限查询成功
@@ -64,11 +64,51 @@ paths:
schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
+ /api/platform/v1/iam/tenants/{tenantId}:
+ parameters:
+ - {$ref: '#/components/parameters/TenantId'}
+ get:
+ tags: [Platform Tenant / 平台租户]
+ summary: 查询企业客户档案
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '404': {$ref: '#/components/responses/NotFound'}
+ put:
+ tags: [Platform Tenant / 平台租户]
+ summary: 编辑企业客户档案
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/UpdateTenantRequest'}
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '404': {$ref: '#/components/responses/NotFound'}
+ /api/platform/v1/iam/tenants/{tenantId}/status:
+ parameters:
+ - {$ref: '#/components/parameters/TenantId'}
+ put:
+ tags: [Platform Tenant / 平台租户]
+ summary: 启停企业客户
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/ChangeTenantStatusRequest'}
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '404': {$ref: '#/components/responses/NotFound'}
+ '409': {$ref: '#/components/responses/Conflict'}
/api/merchant/v1/iam/authorization/permissions:
get:
tags: [IAM Resources / IAM 资源]
- summary: Get current effective permissions / 查询当前用户有效权限
- description: Returns the current active merchant membership's permission codes. / 返回当前有效商户成员权限编码。
+ summary: 查询当前用户有效权限
+ description: 返回当前有效商户成员权限编码。
responses:
'200':
description: 当前商户成员权限查询成功
@@ -193,6 +233,27 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
+ /api/platform/v1/iam/internal-users/{membershipId}/roles:
+ parameters:
+ - {$ref: '#/components/parameters/MembershipId'}
+ put:
+ tags: [IAM 资源]
+ summary: 全量分配平台内部用户角色
+ description: 仅允许分配平台租户下已启用的平台端角色;空集合表示清空角色。
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/AssignInternalUserRolesRequest'}
+ responses:
+ '200':
+ description: 角色分配成功并返回最新内部用户模型
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/InternalUserApiResponse'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '404': {$ref: '#/components/responses/NotFound'}
/api/merchant/v1/iam/tenant/profile:
get:
tags: [Merchant Tenant / 商户企业]
@@ -341,6 +402,49 @@ paths:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
+ /api/{app}/v1/iam/permissions/{permissionId}:
+ parameters:
+ - {$ref: '#/components/parameters/App'}
+ - {$ref: '#/components/parameters/PermissionId'}
+ put:
+ tags: [RBAC]
+ summary: 编辑自定义权限资源
+ description: 权限编码和类型不可变更,系统预置资源不可修改。
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/UpdatePermissionRequest'}
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
+ delete:
+ tags: [RBAC]
+ summary: 删除未绑定的自定义权限资源
+ description: 系统预置资源、存在子资源或已绑定角色的资源不可删除。
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
+ /api/{app}/v1/iam/permissions/{permissionId}/status:
+ parameters:
+ - {$ref: '#/components/parameters/App'}
+ - {$ref: '#/components/parameters/PermissionId'}
+ put:
+ tags: [RBAC]
+ summary: 启停自定义权限资源
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/ChangePermissionStatusRequest'}
+ responses:
+ '200': {$ref: '#/components/responses/Success'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
components:
securitySchemes:
bearerAuth:
@@ -366,7 +470,19 @@ components:
name: membershipId
in: path
required: true
- description: Platform membership ID / 平台成员关系 ID
+ description: 平台成员关系 ID
+ schema: {type: integer, format: int64}
+ TenantId:
+ name: tenantId
+ in: path
+ required: true
+ description: 企业客户租户 ID
+ schema: {type: integer, format: int64, minimum: 1}
+ PermissionId:
+ name: permissionId
+ in: path
+ required: true
+ description: 权限资源 ID
schema: {type: integer, format: int64}
responses:
Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
@@ -416,6 +532,12 @@ components:
- type: object
properties:
data: {type: array, items: {$ref: '#/components/schemas/InternalUserResponse'}}
+ InternalUserApiResponse:
+ allOf:
+ - {$ref: '#/components/schemas/ApiResponse'}
+ - type: object
+ properties:
+ data: {$ref: '#/components/schemas/InternalUserResponse'}
TenantResponse:
type: object
required: [id, tenantCode, tenantName, status]
@@ -448,6 +570,20 @@ components:
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
+ UpdateTenantRequest:
+ type: object
+ required: [tenantName, legalName, contactName, contactPhone]
+ properties:
+ tenantName: {type: string}
+ legalName: {type: string}
+ contactName: {type: string}
+ contactPhone: {type: string}
+ contactEmail: {type: string, format: email}
+ ChangeTenantStatusRequest:
+ type: object
+ required: [status]
+ properties:
+ status: {type: string, enum: [ACTIVE, DISABLED]}
ReviewRequest:
type: object
properties:
@@ -484,6 +620,11 @@ components:
required: [newPassword]
properties:
newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
+ AssignInternalUserRolesRequest:
+ type: object
+ required: [roleIds]
+ properties:
+ roleIds: {type: array, items: {type: integer, format: int64}}
InternalUserResponse:
type: object
required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds]
@@ -541,6 +682,20 @@ components:
routePath: {type: string}
httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]}
sortNo: {type: integer, example: 10}
+ UpdatePermissionRequest:
+ type: object
+ required: [name]
+ properties:
+ name: {type: string}
+ parentId: {type: integer, format: int64}
+ routePath: {type: string}
+ httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]}
+ sortNo: {type: integer}
+ ChangePermissionStatusRequest:
+ type: object
+ required: [status]
+ properties:
+ status: {type: string, enum: [ACTIVE, DISABLED]}
AssignRolesRequest:
type: object
required: [membershipId, roleIds]
diff --git a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java
index b2b01833f..5227e8f36 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/AuthorizationControllerContractTest.java
@@ -1,6 +1,7 @@
package com.baiyue.iam.controller;
import com.baiyue.common.security.AuthenticatedUser;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -10,9 +11,10 @@ import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+@DisplayName("权限快照接口契约")
class AuthorizationControllerContractTest {
@Test
- void 当前权限接口不应依赖角色管理权限() throws Exception {
+ void currentPermissionsEndpointDoesNotRequireRoleManagementPermission() throws Exception {
Method method = AuthorizationController.class.getMethod("permissions", String.class,
AuthenticatedUser.class);
diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java
index 53e653a26..c4b06bf0b 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java
@@ -1,5 +1,6 @@
package com.baiyue.iam.controller;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
@@ -9,9 +10,10 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("IAM OpenAPI 契约")
class IamOpenApiContractTest {
@Test
- void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException {
+ void openApiDeclaresPermissionPaginationAndInternalUserResponseSchema() throws IOException {
String document;
try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) {
assertThat(stream).isNotNull();
@@ -23,6 +25,8 @@ class IamOpenApiContractTest {
.contains("PermissionCodesApiResponse:")
.contains("TenantPageApiResponse:")
.contains("InternalUserListApiResponse:")
+ .contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:")
+ .contains("AssignInternalUserRolesRequest:")
.contains("membershipId: {type: integer, format: int64}")
.contains("roleIds: {type: array, items: {type: integer, format: int64}}")
.contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
diff --git a/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java
index f55bb491c..9f18fc398 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/MerchantMemberRouteTest.java
@@ -1,5 +1,6 @@
package com.baiyue.iam.controller;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.util.pattern.PathPatternParser;
import org.springframework.http.server.PathContainer;
@@ -7,11 +8,12 @@ import org.springframework.http.server.PathContainer;
import static org.assertj.core.api.Assertions.assertThat;
/** 防止成员数字主键路由再次拦截固定的角色分配路径。 */
+@DisplayName("商户成员路由")
class MerchantMemberRouteTest {
private final PathPatternParser parser = new PathPatternParser();
@Test
- void 成员编辑路由只接受数字主键() {
+ void memberUpdateRouteAcceptsNumericPrimaryKeyOnly() {
var pattern = parser.parse("/api/merchant/v1/iam/members/{membershipId:\\d+}");
assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/123"))).isTrue();
diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java
index 34446beb9..eb5a40c10 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformInternalUserContractTest.java
@@ -1,5 +1,6 @@
package com.baiyue.iam.controller;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -9,10 +10,11 @@ import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
+@DisplayName("平台内部用户接口契约")
class PlatformInternalUserContractTest {
@Test
- void 内部用户列表必须校验列表权限() throws Exception {
+ void internalUserListRequiresListPermission() throws Exception {
Method method = ProfileResourceController.class.getMethod("internalUsers",
com.baiyue.common.security.AuthenticatedUser.class);
@@ -23,16 +25,18 @@ class PlatformInternalUserContractTest {
}
@Test
- void 平台用户写接口以成员关系主键寻址() {
+ void platformUserWriteEndpointsAddressMembershipId() {
assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}");
assertMapping("changeInternalUserStatus",
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status");
assertMapping("resetInternalUserPassword",
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password");
+ assertMapping("assignInternalUserRoles",
+ "/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles");
}
@Test
- void 企业资料查看必须校验查看权限() throws Exception {
+ void tenantProfileReadRequiresViewPermission() throws Exception {
Method method = MerchantTenantController.class.getMethod("profile",
com.baiyue.common.security.AuthenticatedUser.class);
assertEquals("hasAuthority('merchant:profile:view')",
diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
index 509ba8559..2b9edd4d1 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
@@ -18,9 +19,10 @@ import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+@DisplayName("平台客户接口契约")
class PlatformTenantControllerContractTest {
@Test
- void 客户分页接口声明权限分页约束和错误状态() throws Exception {
+ void merchantPageEndpointDeclaresPermissionPaginationConstraintsAndErrorStatuses() throws Exception {
Method method = PlatformTenantController.class.getMethod("page", long.class, long.class,
String.class, String.class, AuthenticatedUser.class);
@@ -39,6 +41,14 @@ class PlatformTenantControllerContractTest {
assertEquals(Set.of("200", "400", "401", "403", "422"), statuses);
}
+ @Test
+ void pendingMerchantListRequiresMerchantApprovalPermission() throws Exception {
+ Method method = PlatformTenantController.class.getMethod("reviews", AuthenticatedUser.class);
+
+ assertEquals("hasAuthority('platform:customer:review')",
+ method.getAnnotation(PreAuthorize.class).value());
+ }
+
private T annotation(Method method, int parameter, Class type) {
return method.getParameters()[parameter].getAnnotation(type);
}
diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java
index cc6af9acd..ee2f47c7e 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java
@@ -1,5 +1,6 @@
package com.baiyue.iam.controller;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
@@ -10,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+@DisplayName("企业资质上传接口契约")
class QualificationUploadContractTest {
@Test
diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java
index b878d69a8..4829b6f69 100644
--- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java
@@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -16,6 +17,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+@DisplayName("权限快照服务")
class AuthorizationSnapshotServiceTest {
@Test
void loadsRolesAndPermissionsForActiveMerchantIdentity() {
@@ -82,7 +84,7 @@ class AuthorizationSnapshotServiceTest {
}
@Test
- void 空角色和权限查询结果不应触发五百错误() {
+ void emptyRoleAndPermissionQueriesDoNotTriggerServerError() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java
index a9219af34..8034e5a10 100644
--- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java
@@ -10,6 +10,7 @@ import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -18,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
+@DisplayName("商户成员服务")
class MemberServiceTest {
private final MemberRepository repository = mock(MemberRepository.class);
private final IamRoleMapper roleMapper = mock(IamRoleMapper.class);
@@ -35,7 +37,7 @@ class MemberServiceTest {
when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L));
}
- @Test void 成员详情返回联系方式和已有角色() {
+ @Test void memberDetailsReturnContactInformationAndExistingRoles() {
user.setPhone("13800138000"); user.setEmail("member@example.com");
MemberResponse result = service.detail(100L, 20L);
assertEquals("测试成员", result.displayName());
@@ -44,12 +46,12 @@ class MemberServiceTest {
verifyNoInteractions(auth);
}
- @Test void 跨租户查询被拒绝() {
+ @Test void crossTenantLookupIsRejected() {
when(repository.findByTenantAndId(200L, 20L)).thenReturn(null);
assertThrows(BizException.class, () -> service.detail(200L, 20L));
}
- @Test void 企业所有者不可停用() {
+ @Test void tenantOwnerCannotBeDisabled() {
member.setMemberType("OWNER");
BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L,
new ChangeMemberStatusRequest("DISABLED"), 20L));
@@ -57,7 +59,7 @@ class MemberServiceTest {
verify(auth, never()).status(anyString(), anyLong(), any());
}
- @Test void 停用成员同步认证账号并保存本地状态() {
+ @Test void disablingMemberSynchronizesAuthAccountAndPersistsLocalStatus() {
when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null));
MemberResponse result = service.changeStatus(100L, 20L,
new ChangeMemberStatusRequest("DISABLED"), 99L);
@@ -66,14 +68,14 @@ class MemberServiceTest {
verify(repository).saveUser(user);
}
- @Test void 认证服务失败时不得写入本地状态() {
+ @Test void authServiceFailureDoesNotPersistLocalStatus() {
when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败"));
assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L,
new ChangeMemberStatusRequest("DISABLED"), 99L));
verify(repository, never()).save(member);
}
- @Test void 企业所有者不能通过成员编辑接口降级或变更角色() {
+ @Test void tenantOwnerCannotBeDowngradedOrHaveRolesChangedThroughMemberUpdate() {
member.setMemberType("OWNER");
UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null,
"EMPLOYEE", null, List.of());
@@ -83,7 +85,7 @@ class MemberServiceTest {
verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList());
}
- @Test void 企业所有者密码不能被成员管理员重置() {
+ @Test void tenantOwnerPasswordCannotBeResetByMemberAdministrator() {
member.setMemberType("OWNER");
assertThrows(BizException.class, () -> service.resetPassword(100L, 20L,
diff --git a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java
index ce88c4f79..146c757f5 100644
--- a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java
@@ -5,16 +5,18 @@ import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.MemberRepository;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
+@DisplayName("个人资料服务")
class PersonalProfileServiceTest {
private final MemberRepository repository = mock(MemberRepository.class);
private final PersonalProfileService service = new PersonalProfileService(repository);
- @Test void 只能修改当前租户中的本人资料() {
+ @Test void canOnlyModifyOwnProfileWithinCurrentTenant() {
IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE");
IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L);
when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership);
@@ -25,7 +27,7 @@ class PersonalProfileServiceTest {
verify(repository).saveUser(user);
}
- @Test void 跨租户身份被拒绝() {
+ @Test void crossTenantIdentityIsRejected() {
assertThrows(BizException.class, () -> service.get(9L, 2L, "tester"));
}
}
diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java
index c56da2c79..fd91f9bb7 100644
--- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java
@@ -10,6 +10,7 @@ import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -19,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
+@DisplayName("平台内部用户服务")
class PlatformInternalUserServiceTest {
private final MemberRepository repository = mock(MemberRepository.class);
private final IamRoleMapper roleMapper = mock(IamRoleMapper.class);
@@ -45,7 +47,7 @@ class PlatformInternalUserServiceTest {
}
@Test
- void 列表返回成员主键联系方式和当前角色() {
+ void listReturnsMembershipIdContactInformationAndCurrentRoles() {
when(repository.findByTenant(0L)).thenReturn(List.of(membership));
InternalUserResponse result = service.list().get(0);
@@ -57,7 +59,7 @@ class PlatformInternalUserServiceTest {
}
@Test
- void 新建平台用户在Auth创建平台账号并写入IAM成员() {
+ void createPlatformUserCreatesPlatformAccountInAuthAndMembershipInIam() {
IamRole role = new IamRole();
role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE");
when(roleMapper.selectById(40L)).thenReturn(role);
@@ -81,7 +83,7 @@ class PlatformInternalUserServiceTest {
}
@Test
- void 本地创建失败会补偿认证账号且不记录密码() {
+ void localCreationFailureCompensatesAuthAccountWithoutRecordingPassword() {
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE")));
when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null));
@@ -95,7 +97,7 @@ class PlatformInternalUserServiceTest {
}
@Test
- void 不能停用当前登录的平台成员() {
+ void cannotDisableCurrentlyAuthenticatedPlatformMember() {
assertThrows(BizException.class, () -> service.changeStatus(30L,
new ChangeInternalUserStatusRequest("DISABLED"), 30L));
@@ -103,7 +105,7 @@ class PlatformInternalUserServiceTest {
}
@Test
- void 状态本地写入失败时恢复Auth且成功后不再远程查询() {
+ void localStatusPersistenceFailureRestoresAuthAndAvoidsFurtherRemoteLookupsAfterSuccess() {
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user);
@@ -118,7 +120,7 @@ class PlatformInternalUserServiceTest {
}
@Test
- void 状态补偿应恢复Auth原状态而不是IAM旧状态() {
+ void statusCompensationRestoresOriginalAuthStatusInsteadOfOldIamStatus() {
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED")));
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
@@ -134,7 +136,7 @@ class PlatformInternalUserServiceTest {
}
@Test
- void 重置密码只转交Auth不写入IAM() {
+ void resetPasswordDelegatesOnlyToAuthWithoutWritingIam() {
when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1"));
@@ -143,4 +145,30 @@ class PlatformInternalUserServiceTest {
argThat(value -> "ResetPass1".equals(value.newPassword())));
verify(repository, never()).saveUser(any());
}
+
+ @Test
+ void roleAssignmentAcceptsEnabledPlatformRolesOnlyAndReturnsLatestView() {
+ IamRole role = new IamRole();
+ role.setId(41L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE");
+ when(roleMapper.selectById(41L)).thenReturn(role);
+
+ InternalUserResponse result = service.assignRoles(30L,
+ new AssignInternalUserRolesRequest(List.of(41L, 41L)));
+
+ assertEquals(List.of(41L), result.roleIds());
+ verify(repository).replaceRoles(0L, 30L, List.of(41L));
+ }
+
+ @Test
+ void preventsAssigningMerchantRolesToPlatformUser() {
+ IamRole merchantRole = new IamRole();
+ merchantRole.setId(42L); merchantRole.setTenantId(10001L);
+ merchantRole.setAppCode("MERCHANT"); merchantRole.setStatus("ACTIVE");
+ when(roleMapper.selectById(42L)).thenReturn(merchantRole);
+
+ assertThrows(BizException.class, () -> service.assignRoles(30L,
+ new AssignInternalUserRolesRequest(List.of(42L))));
+
+ verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList());
+ }
}
diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java
index 3e8800d72..48ac78566 100644
--- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java
@@ -8,12 +8,15 @@ import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest;
import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest;
import com.baiyue.iam.dto.RbacDtos.ChangeRoleStatusRequest;
import com.baiyue.iam.dto.RbacDtos.CreatePermissionRequest;
+import com.baiyue.iam.dto.RbacDtos.UpdatePermissionRequest;
+import com.baiyue.iam.dto.RbacDtos.ChangePermissionStatusRequest;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamPermission;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.RbacRepository;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -29,6 +32,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
+@DisplayName("角色与权限服务")
class RbacServiceTest {
@Mock private RbacRepository repository;
@Mock private MemberRepository memberRepository;
@@ -42,7 +46,7 @@ class RbacServiceTest {
}
@Test
- void 角色列表返回当前权限用于安全回显() {
+ void roleListReturnsCurrentPermissionsForSafeView() {
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role));
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L));
@@ -54,7 +58,7 @@ class RbacServiceTest {
}
@Test
- void 权限列表同码时优先当前租户并去除全局模板重复项() {
+ void permissionListPrefersCurrentTenantAndRemovesDuplicateGlobalTemplatesWithSameCode() {
IamPermission global = permission(21L, 0L, "merchant:member:list");
IamPermission tenant = permission(22L, 100L, "merchant:member:list");
IamPermission fallback = permission(23L, 0L, "merchant:member:status");
@@ -69,7 +73,7 @@ class RbacServiceTest {
}
@Test
- void 跨租户角色访问被拒绝() {
+ void crossTenantRoleAccessIsRejected() {
when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null);
BizException exception = assertThrows(BizException.class,
@@ -79,7 +83,7 @@ class RbacServiceTest {
}
@Test
- void 系统角色禁止删除() {
+ void systemRoleCannotBeDeleted() {
when(repository.findRole(11L, 0L, "PLATFORM"))
.thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM"));
@@ -91,7 +95,7 @@ class RbacServiceTest {
}
@Test
- void 已分配成员的自定义角色禁止删除() {
+ void customRoleAssignedToMembersCannotBeDeleted() {
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
when(repository.isRoleAssigned(100L, 11L)).thenReturn(true);
@@ -104,7 +108,7 @@ class RbacServiceTest {
}
@Test
- void 空权限集合可以明确清空授权() {
+ void emptyPermissionSetExplicitlyClearsAuthorization() {
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
@@ -114,7 +118,7 @@ class RbacServiceTest {
}
@Test
- void 空角色集合可以明确清空成员授权() {
+ void emptyRoleSetExplicitlyClearsMemberAuthorization() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
@@ -126,7 +130,7 @@ class RbacServiceTest {
}
@Test
- void 成员角色回显只返回当前应用域角色() {
+ void memberRoleViewReturnsRolesFromCurrentApplicationDomainOnly() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
@@ -142,7 +146,7 @@ class RbacServiceTest {
}
@Test
- void 同一租户同一应用角色编码重复时返回冲突() {
+ void duplicateRoleCodeWithinTenantAndApplicationReturnsConflict() {
when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true);
BizException exception = assertThrows(BizException.class, () -> service.createRole(
@@ -153,7 +157,7 @@ class RbacServiceTest {
}
@Test
- void 停用角色不得继续分配给成员() {
+ void disabledRoleCannotBeAssignedToMember() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
@@ -170,7 +174,7 @@ class RbacServiceTest {
}
@Test
- void 自定义角色可以编辑和停用并保持权限回显() {
+ void customRoleCanBeUpdatedAndDisabledWhileRetainingPermissionView() {
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role);
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L));
@@ -187,7 +191,7 @@ class RbacServiceTest {
}
@Test
- void 未使用的自定义角色可以安全删除() {
+ void unreferencedCustomRoleCanBeDeletedSafely() {
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
when(repository.isRoleAssigned(100L, 11L)).thenReturn(false);
@@ -198,7 +202,7 @@ class RbacServiceTest {
}
@Test
- void 企业所有者角色不能通过普通成员授权接口修改() {
+ void tenantOwnerRolesCannotBeChangedThroughRegularMemberAuthorizationEndpoint() {
IamMembership owner = new IamMembership();
owner.setId(31L);
owner.setTenantId(100L);
@@ -213,7 +217,7 @@ class RbacServiceTest {
}
@Test
- void 系统角色权限不能通过普通授权接口修改() {
+ void systemRolePermissionsCannotBeChangedThroughRegularAuthorizationEndpoint() {
when(repository.findRole(11L, 0L, "PLATFORM"))
.thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM"));
@@ -225,7 +229,7 @@ class RbacServiceTest {
}
@Test
- void 企业所有者系统角色不能分配给普通成员() {
+ void tenantOwnerSystemRoleCannotBeAssignedToRegularMember() {
IamMembership member = new IamMembership();
member.setId(31L);
member.setTenantId(100L);
@@ -243,7 +247,7 @@ class RbacServiceTest {
}
@Test
- void 权限编码重复时返回稳定冲突且不写数据库() {
+ void duplicatePermissionCodeReturnsStableConflictWithoutDatabaseWrite() {
when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test"))
.thenReturn(true);
CreatePermissionRequest request = new CreatePermissionRequest(
@@ -256,6 +260,67 @@ class RbacServiceTest {
verify(repository, never()).insertPermission(any());
}
+ @Test
+ void builtInSystemPermissionCannotBeUpdatedDisabledOrDeleted() {
+ IamPermission builtIn = permission(21L, 0L, "platform:permission:list");
+ builtIn.setAppCode("PLATFORM");
+ builtIn.setBuiltIn(true);
+ when(repository.findOwnedPermission(21L, 0L, "PLATFORM")).thenReturn(builtIn);
+
+ BizException exception = assertThrows(BizException.class, () -> service.changePermissionStatus(
+ 0L, "PLATFORM", 21L, new ChangePermissionStatusRequest("DISABLED")));
+
+ assertEquals(ResultCode.CONFLICT, exception.getResultCode());
+ verify(repository, never()).updatePermission(any());
+ verify(repository, never()).deletePermission(any());
+ }
+
+ @Test
+ void customPermissionCanBeUpdatedAndToggledWithoutChangingCode() {
+ IamPermission custom = permission(21L, 100L, "merchant:custom:edit");
+ when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom);
+
+ var updated = service.updatePermission(100L, "MERCHANT", 21L,
+ new UpdatePermissionRequest("新名称", 0L, "/custom", "PUT", 10));
+ var disabled = service.changePermissionStatus(100L, "MERCHANT", 21L,
+ new ChangePermissionStatusRequest("DISABLED"));
+
+ assertEquals("merchant:custom:edit", updated.getPermissionCode());
+ assertEquals("新名称", updated.getPermissionName());
+ assertEquals("DISABLED", disabled.getStatus());
+ verify(repository, times(2)).updatePermission(custom);
+ }
+
+ @Test
+ void permissionResourceWithChildrenOrRoleBindingsRejectsDeletion() {
+ IamPermission custom = permission(21L, 100L, "merchant:custom:edit");
+ when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom);
+ when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(true);
+
+ BizException childFailure = assertThrows(BizException.class,
+ () -> service.deletePermission(100L, "MERCHANT", 21L));
+ assertEquals(ResultCode.CONFLICT, childFailure.getResultCode());
+
+ when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false);
+ when(repository.isPermissionGranted(100L, 21L)).thenReturn(true);
+ BizException bindingFailure = assertThrows(BizException.class,
+ () -> service.deletePermission(100L, "MERCHANT", 21L));
+ assertEquals(ResultCode.CONFLICT, bindingFailure.getResultCode());
+ verify(repository, never()).deletePermission(any());
+ }
+
+ @Test
+ void unreferencedCustomPermissionCanBeDeleted() {
+ IamPermission custom = permission(21L, 100L, "merchant:custom:edit");
+ when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom);
+ when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false);
+ when(repository.isPermissionGranted(100L, 21L)).thenReturn(false);
+
+ service.deletePermission(100L, "MERCHANT", 21L);
+
+ verify(repository).deletePermission(custom);
+ }
+
private IamRole role(Long id, Long tenantId, String appCode, String roleType) {
IamRole role = new IamRole();
role.setId(id);
diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
index e034c58d1..d2995bfaa 100644
--- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
@@ -2,7 +2,11 @@ package com.baiyue.iam.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.iam.entity.IamTenant;
+import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
+import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.repository.TenantRepository;
+import com.baiyue.iam.repository.ProfileResourceRepository;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -12,7 +16,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.core.api.ResultCode;
+@DisplayName("企业租户服务")
class TenantServiceTest {
@Test
@@ -41,4 +49,66 @@ class TenantServiceTest {
assertEquals("Alice", result.records().get(0).contactName());
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE");
}
+
+ @Test
+ void platformCanMaintainAndDisableOnlyMerchantTenant() {
+ TenantRepository repository = mock(TenantRepository.class);
+ TenantService service = new TenantService();
+ ReflectionTestUtils.setField(service, "repository", repository);
+ IamTenant tenant = merchant(10001L, "ACTIVE");
+ when(repository.findTenant(10001L)).thenReturn(tenant);
+
+ var updated = service.updateMerchant(10001L,
+ new UpdateTenantRequest("New name", "New legal", "Alice", "13900139000", "alice@example.com"));
+ var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"));
+
+ assertEquals("New name", updated.tenantName());
+ assertEquals("DISABLED", disabled.status());
+ verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant);
+ }
+
+ @Test
+ void platformCannotBypassReviewToActivatePendingTenant() {
+ TenantRepository repository = mock(TenantRepository.class);
+ TenantService service = new TenantService();
+ ReflectionTestUtils.setField(service, "repository", repository);
+ when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING"));
+
+ BizException exception = assertThrows(BizException.class,
+ () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE")));
+
+ assertEquals(ResultCode.CONFLICT, exception.getResultCode());
+ }
+
+ @Test
+ void activeTenantStillRequiresApprovedQualificationToPlaceOrder() {
+ TenantRepository repository = mock(TenantRepository.class);
+ ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
+ TenantService service = new TenantService();
+ ReflectionTestUtils.setField(service, "repository", repository);
+ ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
+ when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
+ when(profileRepository.countApprovedQualifications(10001L)).thenReturn(0L, 1L);
+
+ var withoutQualification = service.orderEligibility(10001L);
+ var withQualification = service.orderEligibility(10001L);
+
+ assertEquals(false, withoutQualification.eligible());
+ assertEquals("ACTIVE", withoutQualification.tenantStatus());
+ assertEquals(true, withQualification.eligible());
+ }
+
+ private IamTenant merchant(long id, String status) {
+ IamTenant tenant = new IamTenant();
+ tenant.setId(id);
+ tenant.setTenantCode("TEN" + id);
+ tenant.setTenantType("MERCHANT");
+ tenant.setStatus(status);
+ tenant.setTenantName("Example Merchant");
+ tenant.setLegalName("Example Merchant Ltd");
+ tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
+ tenant.setContactName("Alice");
+ tenant.setContactPhone("13800138000");
+ return tenant;
+ }
}
diff --git a/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java
index 489117122..5467823ba 100644
--- a/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java
+++ b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java
@@ -1,5 +1,6 @@
package com.baiyue.integration.config;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.net.URI;
@@ -8,6 +9,7 @@ import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("第三方集成配置功能")
class IntegrationPropertiesTest {
@Test
void providerIsConfiguredOnlyWhenEnabledAndAllSecretsExist() {
diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java
index 707ebee35..1397eb059 100644
--- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java
+++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/ImageCropperTest.java
@@ -1,5 +1,6 @@
package com.baiyue.integration.service.ocr;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -12,6 +13,7 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+@DisplayName("OCR 图像裁剪功能")
class ImageCropperTest {
@TempDir
Path tempDirectory;
diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java
index 63343beb9..aca000d3b 100644
--- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java
+++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightCandidateSelectorTest.java
@@ -1,12 +1,14 @@
package com.baiyue.integration.service.ocr;
import com.baiyue.integration.dto.ocr.OcrCandidate;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("OCR 重量候选值筛选功能")
class WeightCandidateSelectorTest {
private final WeightCandidateSelector selector = new WeightCandidateSelector();
diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java
index 8cfa13385..56dfa1b34 100644
--- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java
+++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java
@@ -6,6 +6,7 @@ import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest;
import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status;
import com.baiyue.integration.provider.ocr.BaiduMeterClient;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.util.unit.DataSize;
@@ -24,6 +25,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+@DisplayName("OCR 重量批量识别功能")
class WeightOcrBatchServiceTest {
@TempDir
Path tempDirectory;
diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java
index badb48dab..c36445292 100644
--- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java
+++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrStoragePolicyTest.java
@@ -3,6 +3,7 @@ package com.baiyue.integration.service.ocr;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.integration.config.IntegrationProperties;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.util.unit.DataSize;
@@ -15,6 +16,7 @@ import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+@DisplayName("OCR 文件存储策略功能")
class WeightOcrStoragePolicyTest {
@TempDir
Path tempDirectory;
diff --git a/order/AGENTS.md b/order/AGENTS.md
index 0c9988190..89bc39abe 100644
--- a/order/AGENTS.md
+++ b/order/AGENTS.md
@@ -30,4 +30,13 @@
- 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
+- 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 500。
- 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。
+
+## 平台代客接口补充
+
+- `POST /api/platform/v1/orders/first-leg?tenantId={tenantId}`:平台代客创建草稿,校验 `platform:order:create`,必须携带 `Idempotency-Key`。
+- `PUT /api/platform/v1/orders/first-leg/{id}?tenantId={tenantId}`:平台编辑草稿,校验 `platform:order:edit`。
+- `POST /api/platform/v1/orders/first-leg/{id}/submit?tenantId={tenantId}`:平台代客提交订单,校验 `platform:order:submit`,必须携带 `Idempotency-Key`。
+- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以租户、操作人、操作和幂等键建立唯一约束。
+- 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。
diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
index 046c3c0d9..020202d32 100644
--- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
+++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
@@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -40,6 +41,71 @@ public class PlatformFirstLegOrderController {
@Resource
private OrderApplicationService service;
+ @BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿")
+ @Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。")
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功或幂等重放成功"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数或幂等键无效"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台创建权限"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或请求处理中"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用")
+ })
+ @PostMapping
+ @PreAuthorize("hasAuthority('platform:order:create')")
+ public ApiResponse create(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
+ @Parameter(description = "创建草稿幂等键,同键同请求返回首次结果", required = true)
+ @RequestHeader("Idempotency-Key") String idempotencyKey,
+ @Valid @RequestBody OrderRequests.SaveRequest request) {
+ long scopedTenant = OrderScopeGuard.platform(user, tenantId);
+ return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey));
+ }
+
+ @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿")
+ @Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。")
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "订单不存在"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "订单当前不可编辑"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用")
+ })
+ @PutMapping("/{id}")
+ @PreAuthorize("hasAuthority('platform:order:edit')")
+ public ApiResponse updateDraft(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
+ @Parameter(description = "订单 ID", required = true) @PathVariable long id,
+ @Valid @RequestBody OrderRequests.SaveRequest request) {
+ long scopedTenant = OrderScopeGuard.platform(user, tenantId);
+ service.updateDraft(scopedTenant, user.userId(), id, request);
+ return ApiResponse.success(null);
+ }
+
+ @BusinessAudit(event = "ORDER_SUBMIT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台代客提交订单")
+ @Operation(summary = "平台代客提交订单", description = "在明确指定的目标租户内提交草稿订单,必须携带幂等键。")
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "提交成功或幂等重放成功"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数或幂等键无效"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台提交权限"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "订单不存在"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或订单状态不可提交"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务不可用")
+ })
+ @PostMapping("/{id}/submit")
+ @PreAuthorize("hasAuthority('platform:order:submit')")
+ public ApiResponse submit(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
+ @Parameter(description = "订单 ID", required = true) @PathVariable long id,
+ @Parameter(description = "提交订单幂等键,同键同请求返回首次结果", required = true)
+ @RequestHeader("Idempotency-Key") String idempotencyKey) {
+ long scopedTenant = OrderScopeGuard.platform(user, tenantId);
+ service.submit(scopedTenant, user.userId(), user.username(), id, idempotencyKey);
+ return ApiResponse.success(null);
+ }
+
@Operation(summary = "Tenant order query / 租户订单查询", description = "Queries a page of first-leg orders in the explicit target tenant. / 分页查询明确目标租户下的头程订单。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
diff --git a/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java
index 06b6b7a2f..15a138d54 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderEligibilityGuard.java
@@ -25,6 +25,11 @@ public class OrderEligibilityGuard {
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM eligibility service is unavailable");
+ } catch (BizException exception) {
+ throw exception;
+ } catch (RuntimeException exception) {
+ // Feign 解码或负载契约漂移不能泄露为通用 500,统一转换为可追踪的下游不可用。
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 下单资格响应无效");
}
}
}
diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql
index 848379129..f4697a289 100644
--- a/order/src/main/resources/db/order.sql
+++ b/order/src/main/resources/db/order.sql
@@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS ord_order (
pickup_method VARCHAR(24) NULL COMMENT '取货方式',
receiving_warehouse_id BIGINT UNSIGNED NULL COMMENT '国内收货仓ID',
receiving_warehouse_name VARCHAR(128) NULL COMMENT '国内收货仓快照',
+ receiving_warehouse_address VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照',
+ receiving_time VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
@@ -31,6 +33,8 @@ CREATE TABLE IF NOT EXISTS ord_order (
amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码',
shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID',
+ express_company VARCHAR(64) NULL COMMENT '履约快递公司名称快照',
+ express_tracking_no VARCHAR(128) NULL COMMENT '履约快递单号',
submitted_at DATETIME(3) NULL COMMENT '提交时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql
index 8d5e57d88..44cfc7162 100644
--- a/order/src/main/resources/db/phase1-order-extension.sql
+++ b/order/src/main/resources/db/phase1-order-extension.sql
@@ -28,6 +28,8 @@ DELIMITER ;
CALL phase1_add_column('ord_order', 'pickup_method', "VARCHAR(24) NULL COMMENT 'Pickup method'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_id', "BIGINT UNSIGNED NULL COMMENT 'Domestic receiving warehouse ID'");
CALL phase1_add_column('ord_order', 'receiving_warehouse_name', "VARCHAR(128) NULL COMMENT 'Domestic receiving warehouse snapshot'");
+CALL phase1_add_column('ord_order', 'receiving_warehouse_address', "VARCHAR(512) NULL COMMENT '国内收货仓完整地址快照'");
+CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT '国内收货仓收货时间快照'");
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
@@ -37,6 +39,8 @@ CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMEN
CALL phase1_add_column('ord_order', 'amazon_warehouse_code', "VARCHAR(32) NULL COMMENT 'Amazon warehouse code'");
CALL phase1_add_column('ord_order', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Amazon Shipment ID'");
CALL phase1_add_column('ord_order', 'reference_id', "VARCHAR(64) NULL COMMENT 'Amazon Reference ID'");
+CALL phase1_add_column('ord_order', 'express_company', "VARCHAR(64) NULL COMMENT '履约快递公司名称快照'");
+CALL phase1_add_column('ord_order', 'express_tracking_no', "VARCHAR(128) NULL COMMENT '履约快递单号'");
CALL phase1_add_column('ord_order_cargo', 'sku_no', "VARCHAR(64) NULL COMMENT 'SKU number'");
CALL phase1_add_column('ord_order_cargo', 'upc_no', "VARCHAR(64) NULL COMMENT 'UPC number'");
@@ -52,3 +56,22 @@ CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Mo
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
DROP PROCEDURE phase1_add_column;
+
+-- 已有订单库的幂等记录迁移,和订单写入处于同一个本地事务。
+CREATE TABLE IF NOT EXISTS ord_idempotency_record (
+ id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录 ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户 ID',
+ operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人 ID',
+ operation VARCHAR(32) NOT NULL COMMENT '操作类型',
+ resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源 ID',
+ idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
+ request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
+ status VARCHAR(16) NOT NULL COMMENT '处理状态',
+ result_order_id BIGINT UNSIGNED NULL COMMENT '重放结果订单 ID',
+ completed_at DATETIME(3) NULL COMMENT '完成时间',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key),
+ KEY idx_order_idempotency_created (created_at)
+) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录';
diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml
index 8e5eeac26..f73723fef 100644
--- a/order/src/main/resources/openapi/order-v1.yaml
+++ b/order/src/main/resources/openapi/order-v1.yaml
@@ -42,6 +42,62 @@ paths:
description: 幂等键冲突、请求处理中或订单状态冲突
'503':
description: 仓库或往来单位下游服务异常
+ /api/platform/v1/orders/first-leg:
+ post:
+ summary: 平台代客创建订单草稿
+ parameters:
+ - $ref: '#/components/parameters/TenantId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ '200':
+ description: 创建成功或同请求重放成功
+ '403':
+ description: 需要 platform:order:create 权限
+ '409':
+ description: 幂等键冲突或请求仍在处理
+ /api/platform/v1/orders/first-leg/{id}/submit:
+ post:
+ summary: 平台代客提交指定订单
+ parameters:
+ - $ref: '#/components/parameters/OrderId'
+ - $ref: '#/components/parameters/TenantId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ responses:
+ '200':
+ description: 提交成功或同请求重放成功
+ '403':
+ description: 需要 platform:order:submit 权限
+ '404':
+ description: 订单不存在
+ '409':
+ description: 幂等键冲突或当前订单不可提交
+ /api/platform/v1/orders/first-leg/{id}:
+ put:
+ summary: 平台编辑指定订单草稿
+ parameters:
+ - $ref: '#/components/parameters/OrderId'
+ - $ref: '#/components/parameters/TenantId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ '200':
+ description: 编辑成功
+ '403':
+ description: 需要 platform:order:edit 权限
+ '404':
+ description: 订单不存在
+ '409':
+ description: 当前订单不可编辑
components:
securitySchemes:
bearerAuth:
@@ -49,6 +105,13 @@ components:
scheme: bearer
bearerFormat: JWT
parameters:
+ TenantId:
+ name: tenantId
+ in: query
+ required: true
+ schema:
+ type: integer
+ format: int64
OrderId:
name: id
in: path
diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
index 08bb1914a..13fe8e123 100644
--- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
+++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
@@ -2,6 +2,7 @@ package com.baiyue.order.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderRequests;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -9,9 +10,10 @@ import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
+@DisplayName("订单接口权限控制")
class OrderControllerPermissionTest {
@Test
- void 商户订单列表和详情具有对应查询权限码() throws Exception {
+ void merchantOrderListAndDetailRequireCorrespondingPermissions() throws Exception {
assertAuthority(MerchantFirstLegOrderController.class, "page", "hasAuthority('merchant:order:list')",
AuthenticatedUser.class, OrderRequests.QueryRequest.class);
assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')",
@@ -19,13 +21,23 @@ class OrderControllerPermissionTest {
}
@Test
- void 平台订单列表和详情使用已配置的订单列表权限码() throws Exception {
+ void platformOrderListAndDetailUseConfiguredListPermission() throws Exception {
assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, long.class);
}
+ @Test
+ void platformAssistedCreateAndEditUseDedicatedPermissions() throws Exception {
+ assertAuthority(PlatformFirstLegOrderController.class, "create", "hasAuthority('platform:order:create')",
+ AuthenticatedUser.class, long.class, String.class, OrderRequests.SaveRequest.class);
+ assertAuthority(PlatformFirstLegOrderController.class, "updateDraft", "hasAuthority('platform:order:edit')",
+ AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class);
+ assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
+ AuthenticatedUser.class, long.class, long.class, String.class);
+ }
+
private void assertAuthority(Class> controller, String methodName, String expected, Class>... parameterTypes)
throws NoSuchMethodException {
Method method = controller.getDeclaredMethod(methodName, parameterTypes);
diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
index ef24b75cd..7844d3e0f 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
@@ -5,14 +5,16 @@ import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
+@DisplayName("订单状态流转")
class OrderApplicationServiceStateTest {
@Test
- void 重复提交返回明确冲突且不重复写库() {
+ void repeatedSubmissionReturnsConflictWithoutDuplicatePersistence() {
OrderRepository repository=mock(OrderRepository.class);
OrderEntity order=new OrderEntity(); order.setId(1L); order.setTenantId(10001L); order.setStatus(OrderStatus.SUBMITTED);
when(repository.findOwned(10001,1)).thenReturn(order);
diff --git a/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java
new file mode 100644
index 000000000..855787fe6
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/service/OrderEligibilityGuardTest.java
@@ -0,0 +1,32 @@
+package com.baiyue.order.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.order.client.TenantEligibilityClient;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@DisplayName("订单下单资格校验")
+class OrderEligibilityGuardTest {
+
+ @Test
+ void downstreamResponseDecodingFailureReturnsServiceUnavailable() {
+ TenantEligibilityClient client = mock(TenantEligibilityClient.class);
+ when(client.check("internal-token", 10001L))
+ .thenThrow(new IllegalStateException("响应契约漂移"));
+
+ OrderEligibilityGuard guard = new OrderEligibilityGuard();
+ ReflectionTestUtils.setField(guard, "client", client);
+ ReflectionTestUtils.setField(guard, "internalToken", "internal-token");
+
+ assertThatThrownBy(() -> guard.requireApproved(10001L))
+ .isInstanceOf(BizException.class)
+ .extracting(exception -> ((BizException) exception).getResultCode())
+ .isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
+ }
+}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java
index fcbe5e8d7..36349aeb8 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java
@@ -6,6 +6,7 @@ import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.dao.DuplicateKeyException;
@@ -21,12 +22,14 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+@DisplayName("订单幂等处理")
class OrderIdempotencyServiceTest {
@Test
- void 同键同请求返回首次创建的订单ID() {
+ void sameKeyAndRequestReturnsFirstCreatedOrderId() {
OrderRepository repository = mock(OrderRepository.class);
OrderIdempotencyRecordEntity existing = completed(998L);
AtomicReference attempted = new AtomicReference<>();
@@ -47,7 +50,7 @@ class OrderIdempotencyServiceTest {
}
@Test
- void 同键不同请求返回冲突() {
+ void sameKeyWithDifferentRequestReturnsConflict() {
OrderRepository repository = mock(OrderRepository.class);
doAnswer(invocation -> { throw new DuplicateKeyException("并发唯一键冲突"); })
.when(repository).insertIdempotency(any());
@@ -64,7 +67,7 @@ class OrderIdempotencyServiceTest {
}
@Test
- void 并发请求尚未完成时返回冲突() {
+ void inProgressConcurrentRequestReturnsConflict() {
OrderRepository repository = mock(OrderRepository.class);
OrderIdempotencyRecordEntity existing = new OrderIdempotencyRecordEntity();
existing.setStatus("PROCESSING");
@@ -84,7 +87,25 @@ class OrderIdempotencyServiceTest {
}
@Test
- void 不存在订单统一返回404() {
+ void submitOrderSameKeyReplayDoesNotEnterStateTransition() {
+ OrderRepository repository = mock(OrderRepository.class);
+ OrderIdempotencyRecordEntity existing = completed(998L);
+ doAnswer(invocation -> {
+ OrderIdempotencyRecordEntity attempted = invocation.getArgument(0);
+ existing.setRequestHash(attempted.getRequestHash());
+ throw new DuplicateKeyException("并发唯一键冲突");
+ }).when(repository).insertIdempotency(any());
+ when(repository.findIdempotency(10001L, 20001L, "SUBMIT_ORDER", "submit-key"))
+ .thenReturn(existing);
+
+ service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key");
+
+ verify(repository, never()).findOwned(anyLong(), anyLong());
+ verify(repository, never()).saveOrder(any());
+ }
+
+ @Test
+ void missingOrderReturnsNotFound() {
OrderRepository repository = mock(OrderRepository.class);
assertThatThrownBy(() -> service(repository).detail(10001L, 404L))
@@ -94,23 +115,42 @@ class OrderIdempotencyServiceTest {
}
@Test
- void SQL唯一键包含租户操作者操作和幂等键() throws Exception {
+ void sqlUniqueKeyContainsTenantOperatorActionAndIdempotencyKey() throws Exception {
String sql = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains(
"UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key)");
}
@Test
- void OpenAPI声明创建和提交均要求幂等键() throws Exception {
+ void openApiDeclaresIdempotencyKeyForCreateAndSubmit() throws Exception {
String openapi = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/merchant/v1/orders/first-leg:")
.contains("/api/merchant/v1/orders/first-leg/{id}/submit:")
+ .contains("/api/platform/v1/orders/first-leg:")
+ .contains("/api/platform/v1/orders/first-leg/{id}/submit:")
+ .contains("platform:order:create")
+ .contains("platform:order:edit")
+ .contains("platform:order:submit")
.contains("name: Idempotency-Key")
.contains("required: true");
}
+ @Test
+ void newAndExistingDatabaseScriptsContainActualOrderWriteFields() throws Exception {
+ String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8);
+ String migration = new ClassPathResource("db/phase1-order-extension.sql")
+ .getContentAsString(StandardCharsets.UTF_8);
+
+ for (String column : new String[] {
+ "receiving_warehouse_address", "receiving_time", "express_company", "express_tracking_no"
+ }) {
+ assertThat(baseline).contains(column);
+ assertThat(migration).contains(column);
+ }
+ }
+
private OrderApplicationService service(OrderRepository repository) {
OrderApplicationService service = new OrderApplicationService();
ReflectionTestUtils.setField(service, "repository", repository);
diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java
index 06a7ec8f3..417ac2277 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Collections;
@@ -13,9 +14,10 @@ import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
+@DisplayName("订单主数据校验")
class OrderMasterDataGuardTest {
@Test
- void 正常校验返回服务端可信快照() {
+ void validMasterDataReturnsTrustedServerSnapshot() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
@@ -28,14 +30,14 @@ class OrderMasterDataGuardTest {
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
}
@Test
- void 停用或跨租户主数据被拒绝() {
+ void inactiveOrCrossTenantMasterDataIsRejected() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10002,7)).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10002,request(7L,8L)))
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
}
@Test
- void 主数据响应含空元素时返回受控业务错误而不是空指针异常() {
+ void masterDataResponseWithNullElementReturnsControlledBusinessError() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"),
@@ -49,7 +51,7 @@ class OrderMasterDataGuardTest {
.isEqualTo(ResultCode.VALIDATION_FAILED);
}
@Test
- void 缺失快照名称的主数据响应按服务不可用处理() {
+ void masterDataResponseMissingSnapshotNameIsServiceUnavailable() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE")));
@@ -60,7 +62,7 @@ class OrderMasterDataGuardTest {
verifyNoInteractions(warehouses);
}
@Test
- void 下游非Feign运行时异常映射为服务不可用() {
+ void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败"));
diff --git a/scripts/migrate-local-qualification-file.ps1 b/scripts/migrate-local-qualification-file.ps1
new file mode 100644
index 000000000..52a74dcfe
--- /dev/null
+++ b/scripts/migrate-local-qualification-file.ps1
@@ -0,0 +1,86 @@
+param(
+ [Parameter(Mandatory = $true)][string]$ImagePath,
+ [Parameter(Mandatory = $true)][string]$StorageRoot,
+ [Parameter(Mandatory = $true)][UInt64]$TenantId,
+ [Parameter(Mandatory = $true)][UInt64]$QualificationId,
+ [Parameter(Mandatory = $true)][string]$OldDocumentUrl,
+ [string]$OutputDirectory = ".\reports\qualification-migration"
+)
+
+$ErrorActionPreference = "Stop"
+$source = (Resolve-Path -LiteralPath $ImagePath).Path
+$file = Get-Item -LiteralPath $source
+if ($file.Length -le 0 -or $file.Length -gt 5MB) {
+ throw "资质图片必须大于 0 字节且不超过 5 MB。"
+}
+
+$bytes = [System.IO.File]::ReadAllBytes($source)
+$isJpeg = $bytes.Length -ge 3 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xD8 -and $bytes[2] -eq 0xFF
+$pngSignature = [byte[]](0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
+$isPng = $bytes.Length -ge 8
+for ($index = 0; $isPng -and $index -lt $pngSignature.Length; $index++) {
+ $isPng = $bytes[$index] -eq $pngSignature[$index]
+}
+if (-not $isJpeg -and -not $isPng) {
+ throw "资质文件签名不是有效的 JPG 或 PNG。"
+}
+
+$extension = if ($isPng) { ".png" } else { ".jpg" }
+$fileName = "{0}{1}" -f ([guid]::NewGuid().ToString()), $extension
+$qualificationDirectory = Join-Path (Join-Path $StorageRoot "qualifications") $TenantId
+$target = Join-Path $qualificationDirectory $fileName
+New-Item -ItemType Directory -Force -Path $qualificationDirectory | Out-Null
+Copy-Item -LiteralPath $source -Destination $target -ErrorAction Stop
+
+$sourceHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash
+$targetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash
+if ($sourceHash -ne $targetHash) {
+ Remove-Item -LiteralPath $target -Force
+ throw "复制后的文件校验和不一致,迁移已终止并清理目标文件。"
+}
+
+$newUrl = "/api/merchant/v1/files/qualifications/$fileName"
+$escapedOldUrl = $OldDocumentUrl.Replace("'", "''")
+$escapedNewUrl = $newUrl.Replace("'", "''")
+$output = [System.IO.Path]::GetFullPath($OutputDirectory)
+New-Item -ItemType Directory -Force -Path $output | Out-Null
+
+$forwardSql = @"
+START TRANSACTION;
+UPDATE baiyue_iam.iam_tenant_qualification
+SET document_url = '$escapedNewUrl'
+WHERE id = $QualificationId
+ AND tenant_id = $TenantId
+ AND document_url = '$escapedOldUrl'
+ AND deleted = 0;
+-- 仅当 ROW_COUNT() = 1 时提交,否则回滚并删除:$target
+COMMIT;
+"@
+$rollbackSql = @"
+START TRANSACTION;
+UPDATE baiyue_iam.iam_tenant_qualification
+SET document_url = '$escapedOldUrl'
+WHERE id = $QualificationId
+ AND tenant_id = $TenantId
+ AND document_url = '$escapedNewUrl'
+ AND deleted = 0;
+-- 仅当 ROW_COUNT() = 1 时提交;确认旧文件可用后再删除:$target
+COMMIT;
+"@
+
+$forwardPath = Join-Path $output "qualification-$QualificationId-forward.sql"
+$rollbackPath = Join-Path $output "qualification-$QualificationId-rollback.sql"
+[System.IO.File]::WriteAllText($forwardPath, $forwardSql, [System.Text.UTF8Encoding]::new($false))
+[System.IO.File]::WriteAllText($rollbackPath, $rollbackSql, [System.Text.UTF8Encoding]::new($false))
+
+[pscustomobject]@{
+ TenantId = $TenantId
+ QualificationId = $QualificationId
+ DocumentUrl = $newUrl
+ PhysicalFile = $target
+ Sha256 = $targetHash
+ ForwardSql = $forwardPath
+ RollbackSql = $rollbackPath
+ MerchantPreview = $newUrl
+ PlatformPreview = "/api/platform/v1/files/qualifications/$TenantId/$fileName"
+}
diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md
index 6cae926bf..3e3b106cb 100644
--- a/warehouse/AGENTS.md
+++ b/warehouse/AGENTS.md
@@ -16,11 +16,13 @@
| 内部往来单位有效性校验 | `controller/InternalPartnerController.java` |
| 平台往来单位增改查与启停 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java` |
| 平台快递公司增改查与启停 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java` |
+| 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` |
+| 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` |
| 仓库业务编排 | `service/WarehouseApplicationService.java` |
| 往来单位查询与停用过滤 | `service/PartnerQueryService.java` |
| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` |
| 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/` |
-| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql` |
+| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` |
| 测试 | `src/test/java/com/baiyue/warehouse/` |
## 修改与验证
@@ -28,4 +30,6 @@
- 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。
- 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。
- 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。
+- 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。
+- `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。
- 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。
diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java
new file mode 100644
index 000000000..d0f7e9680
--- /dev/null
+++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseDataAccessExceptionHandler.java
@@ -0,0 +1,31 @@
+package com.baiyue.warehouse.controller;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.web.CorrelationContext;
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.dao.DataAccessException;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/** 仓储数据库不可用或结构漂移时返回可识别的 503,并保留完整链路日志。 */
+@Order(Ordered.HIGHEST_PRECEDENCE)
+@RestControllerAdvice(basePackages = "com.baiyue.warehouse.controller")
+public class WarehouseDataAccessExceptionHandler {
+ private static final Logger log = LoggerFactory.getLogger(WarehouseDataAccessExceptionHandler.class);
+
+ @ExceptionHandler(DataAccessException.class)
+ public ResponseEntity> handle(DataAccessException exception, HttpServletRequest request) {
+ String requestId = CorrelationContext.requestId(request);
+ String traceId = CorrelationContext.traceId(request);
+ log.error("Warehouse data access failure requestId={} traceId={}", requestId, traceId, exception);
+ ResultCode code = ResultCode.SERVICE_UNAVAILABLE;
+ return ResponseEntity.status(code.httpStatus()).body(ApiResponse.failure(code,
+ "仓储数据库暂不可用,请检查数据源和数据库迁移", requestId, traceId));
+ }
+}
diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java
index e519c0841..c60ea108a 100644
--- a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java
+++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java
@@ -1,11 +1,13 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.warehouse.service.WarehouseSchemaReadinessService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.http.ResponseEntity;
import java.util.Map;
@@ -16,11 +18,22 @@ import java.util.Map;
@RestController
@RequestMapping("/api/platform/v1/warehouses")
public class WarehouseHealthController {
+ private final WarehouseSchemaReadinessService readinessService;
+
+ public WarehouseHealthController(WarehouseSchemaReadinessService readinessService) {
+ this.readinessService = readinessService;
+ }
+
@Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-service. / 返回 warehouse-service 的逻辑运行状态。")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用")
@GetMapping("/status")
- public ApiResponse