提交代码

This commit is contained in:
2026-08-03 15:46:50 +08:00
parent 8f5201112d
commit 9e69eff1c5
26 changed files with 383 additions and 58 deletions
+24 -9
View File
@@ -3,7 +3,7 @@
## 适用范围 ## 适用范围
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。 - 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。
- 开始修改前先读根目录 `README.md`、`pom.xml` 和 `docs/头程项目目录结构与服务说明.md`;业务边界不清时读 `docs/repository-boundary.md`。 - 开始修改前先读根目录 `README.md`、`pom.xml` 和 `docs/头程项目目录结构与服务说明.md`;业务边界以本文件及该说明的“项目定位、主要调用关系、开发和拆分约束”为准。
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。 - 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。
## 模块快速索引 ## 模块快速索引
@@ -11,24 +11,37 @@
| 模块 | 职责 | 首选入口 | | 模块 | 职责 | 首选入口 |
|---|---|---| |---|---|---|
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` | | `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` |
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `api/`、`exception/`、`model/`、`mybatis/` | | `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `ApiResponse.java`、`ResultCode.java`、`exception/`、`model/`、`mybatis/`;成功码为字符串 `"200"` |
| `common-web/` | 全局异常、请求链路标识、长整型 JSON 字符串序列化 | `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` | | `common-web/` | HTTP 200 业务响应、全局异常、请求链路标识、长整型 JSON 字符串序列化 | `CommonWebAutoConfiguration.java`、`GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.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-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` | | `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` |
| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` | | `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` |
| `auth/` | 登录、令牌、注册、账号与密码 | `InternalAccountController.java`、`AccountManagementService.java`、`src/main/resources/db/` | | `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` |
| `iam/` | 租户、商户成员、平台内部用户、企业资料、RBAC 与授权快照 | `ProfileResourceController.java`、`PlatformInternalUserService.java`、`RbacController.java` | | `iam/` | 租户、商户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
| `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` | | `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`TenantOrderCountResponse.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` | | `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
| `warehouse/` | 仓库、地址、服务能力、往来单位与快递公司主数据 | `controller/`、`WarehouseApplicationService.java`、`PlatformPartnerQueryService.java`、`ExpressCompanyQueryService.java` | | `warehouse/` | 仓库、地址、服务能力、往来单位多类型、快递公司与 XLSX 导入 | `controller/`、`PlatformPartnerQueryService.java`、`PartnerImportService.java`、`ExpressCompanyImportService.java` |
| `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;启动时校验存储目录,磁盘故障返回 `S0503`,元数据表结构尚未接入 | | `file/` | 企业资质文件上传、替换、预览与鉴权 | `QualificationFileController.java`、`PlatformQualificationFileController.java`、`LocalQualificationStorage.java`;元数据仍归 IAM,File 自身表结构尚未接入 |
| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 | | `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 | | `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` | | `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` | | `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
## 高频业务检索入口
| 搜索目标 | 先读 |
|---|---|
| 后台用户管理、平台/企业账号、跨租户调动 | `iam/.../controller/PlatformUserManagementController.java` → `service/PlatformUserManagementService.java` → `auth/.../controller/InternalAccountController.java` 的 scope 接口 |
| 企业客户档案、审核、必填资质 | `iam/.../controller/PlatformTenantController.java`、`service/TenantService.java`、`service/ProfileResourceService.java` |
| 客户列表真实运单数 | `order/.../controller/PlatformFirstLegOrderController.java` 的 `/tenant-counts`;不要在 IAM 查订单表 |
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
## 配置、数据库与验证入口 ## 配置、数据库与验证入口
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。 - 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
@@ -37,10 +50,11 @@
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。 - 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。 - `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。 - 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
- 本地运行看 `docs/local-runtime.md`;接口联调看 `docs/phase1-local-api.md`;回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。 - 本地环境切换看 `docs/数据库环境切换说明.md`、`scripts/local-env.ps1` 与 `config/nacos/local/`;接口契约优先读目标模块 `src/main/resources/openapi/` 和 Controller,回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。 - 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。 - IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。 - 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。 - 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。 - `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
@@ -50,6 +64,7 @@
- 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。 - 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。 - 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。 - 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。
- 普通业务成功与失败统一使用 HTTP 200,并以响应体字符串 `code` 判断;`code == "200"` 才成功。网关/Servlet Security 的真实 401/403、协议与基础设施错误、Warehouse readiness 503、Audit 202 和 Event Bridge 内部鉴权边界除外;完整约定见 `docs/统一响应约定改造方案-HTTP200.md`。
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。 - 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
## 本地内部服务令牌 ## 本地内部服务令牌
+2
View File
@@ -20,6 +20,8 @@
## 修改与验证 ## 修改与验证
- 当前接收接口尚未实现内部令牌保护,仅适合受控本地/内网联调;部署到共享或生产环境前必须补齐可信服务身份校验,且不对前端开放。 - 当前接收接口尚未实现内部令牌保护,仅适合受控本地/内网联调;部署到共享或生产环境前必须补齐可信服务身份校验,且不对前端开放。
- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。 - 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。 - 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
- 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。 - 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
+5 -3
View File
@@ -11,13 +11,13 @@
|---|---| |---|---|
| 应用与安全配置 | `src/main/java/com/baiyue/auth/BaiyueAuthApplication.java`、`config/AuthSecurityConfig.java` | | 应用与安全配置 | `src/main/java/com/baiyue/auth/BaiyueAuthApplication.java`、`config/AuthSecurityConfig.java` |
| 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) | | 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) |
| 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`) | | 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`);含创建、资料/状态/密码、`PUT /{id}/scope` 账号归属迁移和补偿删除 |
| 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` | | 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` |
| 账号创建、显示名同步、状态和密码重置 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0` | | 账号创建、显示名同步、状态、密码和归属范围 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0`,范围变更会递增 token version 撤销旧会话 |
| 商户注册协作 | `service/MerchantRegistrationService.java` | | 商户注册协作 | `service/MerchantRegistrationService.java` |
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` | | IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` |
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` | | 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` |
| 请求/响应模型 | `dto/` | | 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、租户、成员与应用端 |
| 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` | | 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` |
| 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` | | 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` |
@@ -27,4 +27,6 @@
- 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。 - 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。 - 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。
- 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。 - 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。
- 平台统一用户管理跨租户移动成员时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 默认租户、成员和应用端;修改该契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。
- 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。 - 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。
+3 -1
View File
@@ -10,7 +10,7 @@
| 功能 | 入口 | | 功能 | 入口 |
|---|---| |---|---|
| 统一响应与分页 | `src/main/java/com/baiyue/common/core/api/ApiResponse.java`、`PageResult.java` | | 统一响应与分页 | `src/main/java/com/baiyue/common/core/api/ApiResponse.java`、`PageResult.java` |
| 返回码 | `src/main/java/com/baiyue/common/core/api/ResultCode.java` | | 返回码与下游 code 还原 | `src/main/java/com/baiyue/common/core/api/ResultCode.java`;成功码是字符串 `"200"`,`ResultCode.of` 的未知 code 降级为 `B0001` |
| 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` | | 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` |
| 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | | 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` |
| MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | | MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` |
@@ -19,5 +19,7 @@
## 修改与验证 ## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。 - 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。 - 新能力只有被多个模块稳定复用时才放入本模块。
- 从父目录运行 `mvn -pl common-core -am test`。 - 从父目录运行 `mvn -pl common-core -am test`。
+1
View File
@@ -17,6 +17,7 @@
## 修改与验证 ## 修改与验证
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。 - 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
- 从父目录运行 `mvn -pl common-openapi -am test`。 - 从父目录运行 `mvn -pl common-openapi -am test`。
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。 - 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
+1
View File
@@ -20,5 +20,6 @@
## 修改与验证 ## 修改与验证
- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。 - Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。 - 从父目录运行 `mvn -pl common-security -am test`。
+5 -3
View File
@@ -11,15 +11,17 @@
|---|---| |---|---|
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
| 全局异常映射 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java` | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
| 异常响应测试 | `src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java` | | 异常响应测试 | `src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java` |
| 长整型 JSON 字符串序列化 | `src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java` | | 长整型 JSON 字符串序列化 | `src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java` |
## 修改与验证 ## 修改与验证
- 修改异常响应时同时检查 HTTP 状态、`ApiResponse` 结构、校验错误和安全异常是否仍一致。 - 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
- 查询参数类型转换失败映射为 400,Bean Validation 约束失败映射为 422,禁止落入通用 500。 - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。 - 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
- 从父目录运行 `mvn -pl common-web -am test`。 - 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
+3 -1
View File
@@ -22,7 +22,7 @@ for sql_file in \
auth/src/main/resources/db/phase1-admin-accounts.sql \ auth/src/main/resources/db/phase1-admin-accounts.sql \
iam/src/main/resources/db/phase1-admin-rbac.sql \ iam/src/main/resources/db/phase1-admin-rbac.sql \
iam/src/main/resources/db/phase1-onboarding.sql \ iam/src/main/resources/db/phase1-onboarding.sql \
iam/src/main/resources/db/phase1-customer-profile-fields.sql \ iam/src/main/resources/db/phase1-customer-profile.sql \
iam/src/main/resources/db/phase1-member-permissions.sql \ iam/src/main/resources/db/phase1-member-permissions.sql \
iam/src/main/resources/db/rbac-role-management-v2.sql \ iam/src/main/resources/db/rbac-role-management-v2.sql \
iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \ iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \
@@ -31,6 +31,8 @@ for sql_file in \
order/src/main/resources/db/phase1-masterdata-extension.sql \ order/src/main/resources/db/phase1-masterdata-extension.sql \
warehouse/src/main/resources/db/warehouse.sql \ warehouse/src/main/resources/db/warehouse.sql \
warehouse/src/main/resources/db/warehouse-partner.sql \ warehouse/src/main/resources/db/warehouse-partner.sql \
warehouse/src/main/resources/db/warehouse-express-company.sql \
warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql \
warehouse/src/main/resources/db/warehouse-seed.sql warehouse/src/main/resources/db/warehouse-seed.sql
do do
if [ ! -f "$sql_file" ]; then if [ ! -f "$sql_file" ]; then
+3 -1
View File
@@ -19,12 +19,14 @@
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 表结构 | `src/main/resources/db/event-bridge.sql` | | 表结构 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` | | 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` |
| 架构边界 | `../docs/two-repository-split.md`、`../docs/repository-boundary.md` | | 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 |
## 修改与验证 ## 修改与验证
- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。 - 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。
- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。 - 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。 - 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。
- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。
- 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。 - 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。
- 从父目录运行 `mvn -pl event-bridge-service -am test`。 - 从父目录运行 `mvn -pl event-bridge-service -am test`。
+9 -4
View File
@@ -13,21 +13,26 @@
| 功能 | 入口 | | 功能 | 入口 |
|---|---| |---|---|
| 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` | | 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` |
| 平台资质查看 | `controller/PlatformQualificationFileController.java` | | 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` |
| 服务状态 | `controller/FileHealthController.java` | | 服务状态 | `controller/FileHealthController.java` |
| 当前本地磁盘实现 | `service/LocalQualificationStorage.java` | | 当前本地磁盘实现 | `service/LocalQualificationStorage.java` |
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` | | 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java` | | IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
| 目标表结构(尚未接入) | `src/main/resources/db/file.sql` | | 目标表结构(尚未接入) | `src/main/resources/db/file.sql` |
| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 | | 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` |
| 上传契约与存储测试 | `src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java`、`service/LocalQualificationStorageTest.java` |
## 修改与验证 ## 修改与验证
- 严格校验租户、文件名/路径、大小、类型和读取权限,防止目录穿越、越权访问和任意文件上传。 - 严格校验租户、文件名/路径、大小、类型和读取权限,防止目录穿越、越权访问和任意文件上传。
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。 - 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
- 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。 - 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。
- 当前没有专属自动化测试;从父目录运行 `mvn -pl file -am test`,并按接口影响验证上传、预览、无权限和非法文件场景。 - 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而上传返回 500。 - 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。 - 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。 - 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。
- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标租户安全清理旧 UUID 文件。
- 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。
- 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。
- `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。
@@ -15,14 +15,16 @@ public interface IamQualificationClient {
@RequestBody QualificationRequest request); @RequestBody QualificationRequest request);
@PostMapping("/platform-qualifications") @PostMapping("/platform-qualifications")
ApiResponse<QualificationResponse> submitPlatform( ApiResponse<PlatformQualificationResponse> submitPlatform(
@RequestHeader("X-Internal-Service-Token") String internalToken, @RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody PlatformQualificationRequest request); @RequestBody PlatformQualificationRequest request);
record QualificationRequest(Long tenantId, String qualificationType, record QualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl) { } String certificateNo, String documentUrl) { }
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { } record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
record PlatformQualificationRequest(Long tenantId, String qualificationType, record PlatformQualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl, String certificateNo, String documentUrl,
java.time.LocalDate validTo) { } java.time.LocalDate validTo, boolean replace) { }
} }
@@ -7,6 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage; import com.baiyue.file.service.LocalQualificationStorage;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.CacheControl; import org.springframework.http.CacheControl;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -27,6 +29,7 @@ import java.time.LocalDate;
@RestController @RestController
@RequestMapping("/api/platform/v1/files/qualifications") @RequestMapping("/api/platform/v1/files/qualifications")
public class PlatformQualificationFileController { public class PlatformQualificationFileController {
private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class);
@Resource private LocalQualificationStorage storage; @Resource private LocalQualificationStorage storage;
@Resource private IamQualificationClient iamClient; @Resource private IamQualificationClient iamClient;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
@@ -39,30 +42,44 @@ public class PlatformQualificationFileController {
@RequestPart("file") MultipartFile file, @RequestPart("file") MultipartFile file,
@RequestParam String qualificationType, @RequestParam String qualificationType,
@RequestParam(required = false) String certificateNo, @RequestParam(required = false) String certificateNo,
@RequestParam(required = false) LocalDate validTo) { @RequestParam(required = false) LocalDate validTo,
@RequestParam(defaultValue = "false") boolean replace) {
requirePlatform(user); requirePlatform(user);
var stored = storage.store(tenantId, file); var stored = storage.store(tenantId, file);
try { try {
var response = iamClient.submitPlatform(internalToken, var response = iamClient.submitPlatform(internalToken,
new IamQualificationClient.PlatformQualificationRequest( new IamQualificationClient.PlatformQualificationRequest(
tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo)); tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo, replace));
if (response == null || !response.successful() || response.data() == null) { if (response == null) {
String message = response == null || response.message() == null || response.message().isBlank() throw new IllegalStateException("IAM qualification response is empty");
? "IAM qualification service is unavailable" : response.message(); }
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()), message); if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
storage.delete(stored);
throw new BizException(code, message);
}
if (response.data() == null) {
throw new IllegalStateException("IAM qualification response data is empty");
} }
var data = response.data(); var data = response.data();
if (!stored.documentUrl().equals(data.replacedDocumentUrl())) {
storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl());
}
return ApiResponse.success(new QualificationFileController.UploadResponse( return ApiResponse.success(new QualificationFileController.UploadResponse(
data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl())); data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl()));
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
storage.delete(stored); LOGGER.error("IAM 平台资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}",
if (exception instanceof BizException business) throw business; tenantId, stored.documentUrl(), exception);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
} }
} }
@GetMapping("/{tenantId}/{fileName}") @GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:review')") @PreAuthorize("hasAuthority('platform:customer:view')")
public ResponseEntity<org.springframework.core.io.Resource> download( public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) { @PathVariable long tenantId, @PathVariable String fileName) {
@@ -39,24 +39,26 @@ public class QualificationFileController {
new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE", new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
certificateNo, stored.documentUrl())); certificateNo, stored.documentUrl()));
if (response == null) { if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed"); throw new IllegalStateException("IAM qualification response is empty");
} }
if (!response.successful()) { if (!response.successful()) {
ResultCode code = ResultCode.of(response.code()); ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank() String message = response.message() == null || response.message().isBlank()
? code.message() : response.message(); ? code.message() : response.message();
storage.delete(stored);
throw new BizException(code, message); throw new BizException(code, message);
} }
if (response.data() == null) { if (response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed"); throw new IllegalStateException("IAM qualification response data is empty");
} }
var data = response.data(); var data = response.data();
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(), return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
data.tenantStatus(), stored.documentUrl())); data.tenantStatus(), stored.documentUrl()));
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
storage.delete(stored); LOGGER.error("IAM 资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}",
if (exception instanceof BizException business) throw business; tenantId, stored.documentUrl(), exception);
LOGGER.error("IAM 资质登记调用失败", exception);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
} }
} }
@@ -115,6 +115,20 @@ public class LocalQualificationStorage {
try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { } try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { }
} }
public void deleteByDocumentUrl(long tenantId, String documentUrl) {
if (documentUrl == null || documentUrl.isBlank()) return;
String prefix = "/api/merchant/v1/files/qualifications/";
if (!documentUrl.startsWith(prefix)) return;
String fileName = documentUrl.substring(prefix.length());
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) return;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory)) return;
try { Files.deleteIfExists(target); } catch (IOException exception) {
LOGGER.warn("旧资质文件清理失败,tenantId={},fileName={}", tenantId, fileName, exception);
}
}
private Path tenantDirectory(long tenantId) { private Path tenantDirectory(long tenantId) {
Path directory = qualificationRoot.resolve(Long.toString(tenantId)).normalize(); Path directory = qualificationRoot.resolve(Long.toString(tenantId)).normalize();
if (!directory.startsWith(qualificationRoot)) { if (!directory.startsWith(qualificationRoot)) {
@@ -1,14 +1,35 @@
package com.baiyue.file.controller; package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Arrays; import java.util.Arrays;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("企业资质文件接口契约功能") @DisplayName("企业资质文件接口契约功能")
class QualificationFileContractTest { class QualificationFileContractTest {
@@ -39,4 +60,139 @@ class QualificationFileContractTest {
.anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals); .anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals);
assertTrue(hasMultipartPost); assertTrue(hasMultipartPost);
} }
@Test
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception {
var method = PlatformQualificationFileController.class.getDeclaredMethod(
"download", AuthenticatedUser.class, long.class, String.class);
assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value());
}
@Test
void platformReplacementIsForwardedAndCleansThePreviousFileAfterIamSucceeds() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf",
"/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf");
var oldUrl = "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.pdf";
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl)));
var response = controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null,
LocalDate.of(2030, 12, 31), true);
var ordered = inOrder(storage, iamClient);
ordered.verify(storage).store(10001L, file);
var requestCaptor = org.mockito.ArgumentCaptor.forClass(
IamQualificationClient.PlatformQualificationRequest.class);
ordered.verify(iamClient).submitPlatform(eq("internal-token"), requestCaptor.capture());
ordered.verify(storage).deleteByDocumentUrl(10001L, oldUrl);
assertTrue(requestCaptor.getValue().replace());
assertEquals(stored.documentUrl(), requestCaptor.getValue().documentUrl());
assertEquals(stored.documentUrl(), response.data().documentUrl());
}
@Test
void platformReplacementNeverDeletesTheCurrentUpload() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var currentUrl = "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf";
var stored = new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf", currentUrl);
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", currentUrl)));
controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true);
verify(storage, never()).deleteByDocumentUrl(10001L, currentUrl);
}
@Test
void platformTransportFailurePreservesTheFileBecauseTheIamOutcomeIsUnknown() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = storedFile();
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class,
() -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored);
}
@Test
void explicitPlatformBusinessRejectionDeletesTheUnreferencedFile() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = storedFile();
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(
ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null));
var exception = assertThrows(BizException.class,
() -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, false));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(storage).delete(stored);
}
@Test
void merchantTransportFailureAlsoPreservesTheFileForReconciliation() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = merchantController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = storedFile();
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class,
() -> controller.upload(merchantUser(), file, null));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored);
}
private static PlatformQualificationFileController platformController(
LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new PlatformQualificationFileController();
ReflectionTestUtils.setField(controller, "storage", storage);
ReflectionTestUtils.setField(controller, "iamClient", iamClient);
ReflectionTestUtils.setField(controller, "internalToken", "internal-token");
return controller;
}
private static QualificationFileController merchantController(
LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new QualificationFileController();
ReflectionTestUtils.setField(controller, "storage", storage);
ReflectionTestUtils.setField(controller, "iamClient", iamClient);
ReflectionTestUtils.setField(controller, "internalToken", "internal-token");
return controller;
}
private static LocalQualificationStorage.StoredFile storedFile() {
return new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf",
"/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf");
}
private static AuthenticatedUser platformUser() {
return new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of());
}
private static AuthenticatedUser merchantUser() {
return new AuthenticatedUser(2L, 10001L, 10001L, 2L, "merchant", "MERCHANT", "WEB", Set.of());
}
} }
@@ -11,6 +11,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -63,6 +64,30 @@ class LocalQualificationStorageTest {
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
} }
@Test
void deletesTheReplacedQualificationByItsValidatedDocumentUrl() {
var storage = new LocalQualificationStorage(storageRoot.toString());
storage.initialize();
var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes());
var stored = storage.store(10001L, upload);
storage.deleteByDocumentUrl(10001L, stored.documentUrl());
assertFalse(Files.exists(stored.path()));
}
@Test
void doesNotDeleteAQualificationForAnUntrustedDocumentUrl() {
var storage = new LocalQualificationStorage(storageRoot.toString());
storage.initialize();
var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes());
var stored = storage.store(10001L, upload);
storage.deleteByDocumentUrl(10001L, "/untrusted/" + stored.fileName());
assertTrue(Files.exists(stored.path()));
}
@Test @Test
void legacySeedPathCannotBePretendedToBeAStoredFile() { void legacySeedPathCannotBePretendedToBeAStoredFile() {
var storage = new LocalQualificationStorage(storageRoot.toString()); var storage = new LocalQualificationStorage(storageRoot.toString());
+4 -3
View File
@@ -17,15 +17,16 @@
| 履约与分配编排 | `service/FulfillmentTaskService.java` | | 履约与分配编排 | `service/FulfillmentTaskService.java` |
| 任务与历史持久化 | `entity/`、`mapper/`、`repository/FulfillmentTaskRepository.java` | | 任务与历史持久化 | `entity/`、`mapper/`、`repository/FulfillmentTaskRepository.java` |
| 表结构与查询索引迁移 | `src/main/resources/db/firstleg.sql`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` | | 表结构与查询索引迁移 | `src/main/resources/db/firstleg.sql`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
| OpenAPI 与测试 | `src/main/resources/openapi/firstleg-v1.yaml`、`src/test/java/com/baiyue/firstleg/` | | OpenAPI 与测试 | `src/main/resources/openapi/firstleg-v1.yaml`、`service/FulfillmentTaskServiceTest.java`、`controller/FulfillmentTaskControllerContractTest.java`(测试路径均从 `src/test/java/com/baiyue/firstleg/` 起算) |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/firstleg-service.yml` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/firstleg-service.yml` |
## 修改与验证 ## 修改与验证
- 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。 - 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。
- 分配操作要保留历史、操作者、时间和幂等语义;平台端接口必须执行权限校验。 - 分配操作要保留历史、操作者、时间和幂等语义;平台端接口必须执行权限校验。
- 所有查询必须带目标租户并在仓储层过滤;列表和分配历史使用统一分页响应,详情不存在时统一返回 404。 - 所有查询必须带目标租户并在仓储层过滤;列表和分配历史使用统一分页响应,详情不存在使用 HTTP 200 + body code `A0404`。
- 普通任务不存在、状态冲突和参数错误均遵循 HTTP 200 + body code,平台 Security 拒绝仍保留真实 403。
- 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。 - 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。
- 当前模块没有专属自动化测试时,不要为小改动擅自引入新框架;至少运行模块构建并记录接口验证。 - 修改任务查询、状态或分配逻辑时同步扩展 Service 测试和 Controller 契约测试,不以模块构建代替行为验证。
- 从父目录运行 `mvn -pl firstleg -am test`。 - 从父目录运行 `mvn -pl firstleg -am test`。
- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。 - 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。
+1
View File
@@ -25,5 +25,6 @@
- 新增服务路由时同步检查本地与 Nacos 配置、访问域、公开路径和 Swagger 聚合;前端只访问网关。 - 新增服务路由时同步检查本地与 Nacos 配置、访问域、公开路径和 Swagger 聚合;前端只访问网关。
- 禁止信任客户端自行传入的身份头;Token Claim、Redis Key 变化要与 `auth/`、`common-security/` 同步。 - 禁止信任客户端自行传入的身份头;Token Claim、Redis Key 变化要与 `auth/`、`common-security/` 同步。
- 普通业务响应由下游服务返回 HTTP 200 + body code;Gateway 自身的认证/会话失败仍保留真实 401/403,不能为了统一响应改成 HTTP 200。
- CORS 生产来源必须为明确 HTTPS 域名,不能放宽为 `*`。 - CORS 生产来源必须为明确 HTTPS 域名,不能放宽为 `*`。
- 从父目录运行 `mvn -pl gateway -am test`。 - 从父目录运行 `mvn -pl gateway -am test`。
+11 -3
View File
@@ -16,8 +16,9 @@
| 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` | | 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` |
| 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` | | 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` |
| 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 | | 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 |
| 平台统一用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;统一维护平台与企业租户账号,选项接口返回租户和角色 |
| 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 | | 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 |
| 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java` | | 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 |
| 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` | | 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` |
| Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.java` | | Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.java` |
| 当前登录成员有效权限 | `controller/AuthorizationController.java` | `AuthorizationSnapshotService.currentPermissions`;实时按有效成员、角色和权限资源计算,不依赖角色管理权限 | | 当前登录成员有效权限 | `controller/AuthorizationController.java` | `AuthorizationSnapshotService.currentPermissions`;实时按有效成员、角色和权限资源计算,不依赖角色管理权限 |
@@ -25,17 +26,23 @@
| 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 | | 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 |
| 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 | | 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 |
| OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 | | OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 |
| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料和 RBAC 测试 | | 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、统一用户、平台资质和 RBAC 测试 |
## 修改与验证 ## 修改与验证
- `/api/platform/v1/iam/users` 是后台“用户管理”的统一入口,覆盖 `tenant_id=0` 的平台账号和企业租户成员;`/iam/internal-users` 是较早的平台内部用户专用入口,定位问题时不要混用 Controller/Service。
- 统一用户创建要求 `platform:user:create` 与 `platform:user:grant-role`;跨租户或修改角色还需授角权限。角色必须属于目标租户和对应 `PLATFORM/MERCHANT` appCode,受保护的平台管理员不得被降权、停用或自停用。
- 跨租户移动用户时必须同步成员租户、角色和 Auth 账号 scope;协作入口为 `client/AuthAccountClient.java` 与 Auth 的 `PUT /internal/auth/accounts/{id}/scope`,任一步失败都应回滚本地事务或执行补偿。
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。 - 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
- 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。
- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。
- 企业送审和审核通过都要求 `BUSINESS_LICENSE` 与 `LEGAL_PERSON_ID`;审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,条件更新负责并发防重。
- 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。 - 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。 - 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 - `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
- `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。 - `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。
- 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。 - 平台企业客户分页仅查询商户租户;分页参数类型错误使用 body code `A0400`,Bean Validation 范围或状态约束错误使用 `A0422`,两者普通业务响应均为 HTTP 200。
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。 - 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。 - 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。 - 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
@@ -43,4 +50,5 @@
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。 - 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。 - SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。 - 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
- 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。 - 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。
@@ -9,6 +9,7 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse;
import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.MerchantOnboardingService;
import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.TenantService;
@@ -50,7 +51,7 @@ public class InternalTenantController {
} }
@PostMapping("/platform-qualifications") @PostMapping("/platform-qualifications")
public ApiResponse<QualificationSubmissionResponse> registerPlatformQualification( public ApiResponse<PlatformQualificationRegistrationResponse> registerPlatformQualification(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PlatformQualificationRegistrationRequest request) { @Valid @RequestBody PlatformQualificationRegistrationRequest request) {
verify(token); verify(token);
@@ -36,7 +36,15 @@ public final class OnboardingDtos {
@NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType, @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
String certificateNo, String certificateNo,
@NotBlank String documentUrl, @NotBlank String documentUrl,
LocalDate validTo LocalDate validTo,
boolean replace
) { }
public record PlatformQualificationRegistrationResponse(
Long qualificationId,
Long reviewId,
String tenantStatus,
String replacedDocumentUrl
) { } ) { }
public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { } public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
@@ -34,6 +34,12 @@ public class ProfileResourceRepository {
public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); } public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); }
public List<IamTenantAddress> findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper<IamTenantAddress>().eq(IamTenantAddress::getTenantId, tenantId)); } public List<IamTenantAddress> findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper<IamTenantAddress>().eq(IamTenantAddress::getTenantId, tenantId)); }
public List<IamTenantQualification> findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>().eq(IamTenantQualification::getTenantId, tenantId)); } public List<IamTenantQualification> findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>().eq(IamTenantQualification::getTenantId, tenantId)); }
public List<IamTenantQualification> findQualificationsForUpdate(long tenantId) {
return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>()
.eq(IamTenantQualification::getTenantId, tenantId)
.orderByAsc(IamTenantQualification::getId)
.last("FOR UPDATE"));
}
public long countApprovedQualifications(long tenantId) { public long countApprovedQualifications(long tenantId) {
return qualificationMapper.selectCount(new LambdaQueryWrapper<IamTenantQualification>() return qualificationMapper.selectCount(new LambdaQueryWrapper<IamTenantQualification>()
.eq(IamTenantQualification::getTenantId, tenantId) .eq(IamTenantQualification::getTenantId, tenantId)
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest; import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantAddress; import com.baiyue.iam.entity.IamTenantAddress;
@@ -92,7 +93,7 @@ public class ProfileResourceService {
@Transactional @Transactional
@BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION", @BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false) resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false)
public QualificationSubmissionResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) { public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) {
IamTenant tenant = tenantRepository.findTenant(request.tenantId()); IamTenant tenant = tenantRepository.findTenant(request.tenantId());
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在");
@@ -100,15 +101,26 @@ public class ProfileResourceService {
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
} }
if (repository.findQualifications(tenant.getId()).stream().anyMatch(item -> IamTenantQualification existing = repository.findQualificationsForUpdate(tenant.getId()).stream()
request.qualificationType().equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus()))) { .filter(item -> request.qualificationType().equals(item.getQualificationType())
&& !"REJECTED".equals(item.getStatus()))
.findFirst().orElse(null);
if (existing != null && !request.replace()) {
throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传"); throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传");
} }
String replacedDocumentUrl = null;
if (existing != null) {
replacedDocumentUrl = existing.getDocumentUrl();
existing.setStatus("REJECTED");
existing.setReviewComment("平台在送审前替换文件");
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(), IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
request.certificateNo(), request.documentUrl()); request.certificateNo(), request.documentUrl());
qualification.setValidTo(request.validTo()); qualification.setValidTo(request.validTo());
repository.saveQualification(qualification); repository.saveQualification(qualification);
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus()); return new PlatformQualificationRegistrationResponse(
qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
} }
private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) { private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) {
@@ -15,6 +15,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -29,11 +30,11 @@ class PlatformQualificationRegistrationTest {
IamTenant tenant = new IamTenant(); IamTenant tenant = new IamTenant();
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
when(tenantRepository.findTenant(10001L)).thenReturn(tenant); when(tenantRepository.findTenant(10001L)).thenReturn(tenant);
when(repository.findQualifications(10001L)).thenReturn(List.of()); when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/file.pdf", 10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/file.pdf",
LocalDate.of(2030, 12, 31))); LocalDate.of(2030, 12, 31), false));
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class); ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository).insertQualification(captor.capture()); verify(repository).insertQualification(captor.capture());
@@ -42,5 +43,34 @@ class PlatformQualificationRegistrationTest {
assertEquals(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo()); assertEquals(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo());
assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertNull(response.reviewId()); assertNull(response.reviewId());
assertNull(response.replacedDocumentUrl());
}
@Test
void platformCanReplaceAnUploadedQualificationBeforeReview() {
ProfileResourceRepository repository = mock(ProfileResourceRepository.class);
TenantRepository tenantRepository = mock(TenantRepository.class);
ProfileResourceService service = new ProfileResourceService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository);
IamTenant tenant = new IamTenant();
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
IamTenantQualification existing = new IamTenantQualification();
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID");
existing.setStatus("PENDING");
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf");
when(tenantRepository.findTenant(10001L)).thenReturn(tenant);
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/new-file.pdf",
LocalDate.of(2031, 12, 31), true));
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository, times(2)).saveQualification(captor.capture());
assertEquals("REJECTED", existing.getStatus());
assertEquals("平台在送审前替换文件", existing.getReviewComment());
assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl());
assertEquals("PENDING", captor.getAllValues().get(1).getStatus());
} }
} }
+6 -2
View File
@@ -11,10 +11,11 @@
|---|---| |---|---|
| 商户订单创建、编辑、列表、详情、提交、取消 | `controller/MerchantFirstLegOrderController.java`;列表校验 `merchant:order:list`,详情校验 `merchant:order:view` | | 商户订单创建、编辑、列表、详情、提交、取消 | `controller/MerchantFirstLegOrderController.java`;列表校验 `merchant:order:list`,详情校验 `merchant:order:view` |
| 平台订单列表、详情、审核、接单、开始、完成、取消 | `controller/PlatformFirstLegOrderController.java`;列表和详情校验 `platform:order:list` | | 平台订单列表、详情、审核、接单、开始、完成、取消 | `controller/PlatformFirstLegOrderController.java`;列表和详情校验 `platform:order:list` |
| 平台批量客户运单数 | `PlatformFirstLegOrderController.java` 的 `GET /tenant-counts`、`service/OrderApplicationService.countByTenantIds`、`repository/OrderRepository.countByTenantIds`、`dto/TenantOrderCountResponse.java` |
| 内部备注与快递信息 | `PlatformFirstLegOrderController.java` | | 内部备注与快递信息 | `PlatformFirstLegOrderController.java` |
| 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | | 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 企业下单资格 | `service/OrderEligibilityGuard.java`、`client/TenantEligibilityClient.java` | | 企业下单资格 | `service/OrderEligibilityGuard.java`、`client/TenantEligibilityClient.java` |
| 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java` | | 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java`;往来单位响应同时包含多值 `types` 与兼容字段 `partnerType` |
| 租户与访问范围 | `service/OrderScopeGuard.java` | | 租户与访问范围 | `service/OrderScopeGuard.java` |
| 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` | | 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` |
| 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` | | 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` |
@@ -27,12 +28,15 @@
- 所有商户查询和写入必须执行租户/访问范围校验;平台操作必须验证权限及合法状态迁移。 - 所有商户查询和写入必须执行租户/访问范围校验;平台操作必须验证权限及合法状态迁移。
- 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。 - 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。
- 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。 - 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。
- 客户列表中的真实运单数归 Order,不得写回 IAM。`GET /api/platform/v1/orders/first-leg/tenant-counts?tenantIds=...` 仅允许平台 `platform:order:list`,一次接收 1 至 200 个有效租户 ID,并对没有订单的租户返回 0。
- Warehouse 往来单位已支持多类型;下单校验优先使用 `PartnerClient.Partner.types`,`partnerType` 只作为旧数据兼容。类型语义变化需同步 Warehouse 的 `wh_partner_type`、Order 客户端契约与快照测试。
- 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。 - 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。
- Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。 - Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。 - 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。 - 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
- 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 500。 - 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 HTTP 200 + `S0500`,真实原因只记录在服务日志。
- 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。 - 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。
- 普通业务失败使用 HTTP 200 + body code;Feign 收到 HTTP 非 200 代表鉴权、协议或基础设施异常,应归一为 `S0503`,不能用 Feign 状态码推断 404/409 业务语义。
## 平台代客接口补充 ## 平台代客接口补充
+8 -4
View File
@@ -14,14 +14,14 @@
| 内部仓库有效性校验 | `controller/InternalWarehouseController.java` | | 内部仓库有效性校验 | `controller/InternalWarehouseController.java` |
| 商户往来单位候选与详情 | `controller/MerchantPartnerController.java` | | 商户往来单位候选与详情 | `controller/MerchantPartnerController.java` |
| 内部往来单位有效性校验 | `controller/InternalPartnerController.java` | | 内部往来单位有效性校验 | `controller/InternalPartnerController.java` |
| 平台往来单位增改查与启停 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java` | | 平台往来单位增改查、启停与 XLSX 导入 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java`;模板 `/import-template`,导入 `/import` |
| 平台快递公司增改查与启停 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java` | | 平台快递公司增改查、启停与 XLSX 导入 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java`、`ExpressCompanyImportService.java`;模板 `/import-template`,导入 `/import` |
| 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` | | 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` |
| 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` | | 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` |
| 仓库业务编排 | `service/WarehouseApplicationService.java` | | 仓库业务编排 | `service/WarehouseApplicationService.java` |
| 往来单位查询与停用过滤 | `service/PartnerQueryService.java` | | 往来单位查询与停用过滤 | `service/PartnerQueryService.java` |
| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` | | 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` |
| 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/` | | 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/`;往来单位多类型关联为 `PartnerTypeEntity.java` 与 `wh_partner_type` |
| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.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/` | | 测试 | `src/test/java/com/baiyue/warehouse/` |
@@ -30,8 +30,12 @@
- 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。 - 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。
- 平台仓库管理接口必须同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。 - 平台仓库管理接口必须同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。
- 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。 - 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。
- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`,不存在或跨租户返回 404,记录存在但已停用返回 409;数据库或结构故障保持 503。内部路径不加入 Gateway 公网路由。 - 往来单位类型以 `types` 多值集合和 `wh_partner_type` 为准,允许同一单位同时作为客户、供应商等;`wh_partner.partner_type` 与 DTO 的 `partnerType` 仅保留旧数据兼容。写入时 Entity、Mapper、Repository、平台保存/导入和 Order 客户端必须一起核对。
- 往来单位与快递公司导入只接收标准 XLSX 模板,文件上限 5MB、最多 1000 行;先完成整表头、字段、文件内重复和数据库重复校验,再原子写入,任一行失败整批不落库。权限分别是 `platform:counterparty:import` 与 `platform:express:import`。
- 模板与导入 DTO 在 `dto/MasterDataQueries.java`,Apache POI 依赖在 `pom.xml`;调整模板版本、列顺序或枚举时同步修改生成、解析、错误行号和两组 ImportService 测试。
- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`;不存在或跨租户使用 HTTP 200 + body code `A0404`,已停用使用 HTTP 200 + `A0409`,`WarehouseDataAccessExceptionHandler` 捕获的数据库或结构故障保留真实 HTTP 503 + `S0503`。内部路径不加入 Gateway 公网路由。
- 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。 - 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。
- 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。 - 既有数据库发布必须按 `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`。 - `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。
- Warehouse 的数据库访问异常和 readiness/结构状态探针都保留真实 HTTP 503;其他由公共异常处理器捕获的业务失败才使用 HTTP 200 + body code。
- 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。 - 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。