提交代码
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
## 适用范围
|
||||
|
||||
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。
|
||||
- 开始修改前先读根目录 `README.md`、`pom.xml` 和 `docs/头程项目目录结构与服务说明.md`;业务边界不清时读 `docs/repository-boundary.md`。
|
||||
- 开始修改前先读根目录 `README.md`、`pom.xml` 和 `docs/头程项目目录结构与服务说明.md`;业务边界以本文件及该说明的“项目定位、主要调用关系、开发和拆分约束”为准。
|
||||
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。
|
||||
|
||||
## 模块快速索引
|
||||
@@ -11,24 +11,37 @@
|
||||
| 模块 | 职责 | 首选入口 |
|
||||
|---|---|---|
|
||||
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` |
|
||||
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `api/`、`exception/`、`model/`、`mybatis/` |
|
||||
| `common-web/` | 全局异常、请求链路标识、长整型 JSON 字符串序列化 | `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
|
||||
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `ApiResponse.java`、`ResultCode.java`、`exception/`、`model/`、`mybatis/`;成功码为字符串 `"200"` |
|
||||
| `common-web/` | HTTP 200 业务响应、全局异常、请求链路标识、长整型 JSON 字符串序列化 | `CommonWebAutoConfiguration.java`、`GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
|
||||
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.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/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` |
|
||||
| `auth/` | 登录、令牌、注册、账号与密码 | `InternalAccountController.java`、`AccountManagementService.java`、`src/main/resources/db/` |
|
||||
| `iam/` | 租户、商户成员、平台内部用户、企业资料、RBAC 与授权快照 | `ProfileResourceController.java`、`PlatformInternalUserService.java`、`RbacController.java` |
|
||||
| `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` |
|
||||
| `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` |
|
||||
| `iam/` | 租户、商户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
|
||||
| `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` |
|
||||
| `warehouse/` | 仓库、地址、服务能力、往来单位与快递公司主数据 | `controller/`、`WarehouseApplicationService.java`、`PlatformPartnerQueryService.java`、`ExpressCompanyQueryService.java` |
|
||||
| `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;启动时校验存储目录,磁盘故障返回 `S0503`,元数据表结构尚未接入 |
|
||||
| `warehouse/` | 仓库、地址、服务能力、往来单位多类型、快递公司与 XLSX 导入 | `controller/`、`PlatformPartnerQueryService.java`、`PartnerImportService.java`、`ExpressCompanyImportService.java` |
|
||||
| `file/` | 企业资质文件上传、替换、预览与鉴权 | `QualificationFileController.java`、`PlatformQualificationFileController.java`、`LocalQualificationStorage.java`;元数据仍归 IAM,File 自身表结构尚未接入 |
|
||||
| `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` |
|
||||
| `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/`。
|
||||
@@ -37,10 +50,11 @@
|
||||
- 表结构和增量脚本优先在目标模块 `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/`,历史资质文件迁移使用 `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`。
|
||||
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.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`。
|
||||
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
|
||||
|
||||
@@ -50,6 +64,7 @@
|
||||
- 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。
|
||||
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
|
||||
- 对外 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`。
|
||||
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
|
||||
|
||||
## 本地内部服务令牌
|
||||
|
||||
@@ -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` 与发送实现。
|
||||
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
|
||||
- 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
|
||||
|
||||
+5
-3
@@ -11,13 +11,13 @@
|
||||
|---|---|
|
||||
| 应用与安全配置 | `src/main/java/com/baiyue/auth/BaiyueAuthApplication.java`、`config/AuthSecurityConfig.java` |
|
||||
| 登录、注册、刷新、退出、改密 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/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0` |
|
||||
| 账号创建、显示名同步、状态、密码和归属范围 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0`,范围变更会递增 token version 撤销旧会话 |
|
||||
| 商户注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` |
|
||||
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` |
|
||||
| 请求/响应模型 | `dto/` |
|
||||
| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、租户、成员与应用端 |
|
||||
| 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` |
|
||||
| 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` |
|
||||
|
||||
@@ -27,4 +27,6 @@
|
||||
- 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。
|
||||
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 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/` 回归。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 统一响应与分页 | `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/model/BaseEntity.java` |
|
||||
| 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`。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
## 修改与验证
|
||||
|
||||
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
|
||||
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
|
||||
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
|
||||
- 从父目录运行 `mvn -pl common-openapi -am test`。
|
||||
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
## 修改与验证
|
||||
|
||||
- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。
|
||||
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
|
||||
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
|
||||
- 从父目录运行 `mvn -pl common-security -am test`。
|
||||
|
||||
@@ -11,15 +11,17 @@
|
||||
|---|---|
|
||||
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.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/GlobalExceptionHandlerTest.java` |
|
||||
| 长整型 JSON 字符串序列化 | `src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 修改异常响应时同时检查 HTTP 状态、`ApiResponse` 结构、校验错误和安全异常是否仍一致。
|
||||
- 查询参数类型转换失败映射为 400,Bean Validation 约束失败映射为 422,禁止落入通用 500。
|
||||
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
|
||||
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
|
||||
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
|
||||
- 从父目录运行 `mvn -pl common-web -am test`。
|
||||
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -22,7 +22,7 @@ for sql_file in \
|
||||
auth/src/main/resources/db/phase1-admin-accounts.sql \
|
||||
iam/src/main/resources/db/phase1-admin-rbac.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/rbac-role-management-v2.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 \
|
||||
warehouse/src/main/resources/db/warehouse.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
|
||||
do
|
||||
if [ ! -f "$sql_file" ]; then
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 表结构 | `src/main/resources/db/event-bridge.sql` |
|
||||
| 测试 | `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 哈希,再执行幂等入库。
|
||||
- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。
|
||||
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
|
||||
- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。
|
||||
- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。
|
||||
- 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。
|
||||
- 从父目录运行 `mvn -pl event-bridge-service -am test`。
|
||||
|
||||
+9
-4
@@ -13,21 +13,26 @@
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 商户资质上传与读取 | `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` |
|
||||
| 当前本地磁盘实现 | `service/LocalQualificationStorage.java` |
|
||||
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
|
||||
| IAM 权限协作 | `client/IamQualificationClient.java` |
|
||||
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
|
||||
| 目标表结构(尚未接入) | `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` |
|
||||
| 上传契约与存储测试 | `src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java`、`service/LocalQualificationStorageTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 严格校验租户、文件名/路径、大小、类型和读取权限,防止目录穿越、越权访问和任意文件上传。
|
||||
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
|
||||
- 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。
|
||||
- 当前没有专属自动化测试;从父目录运行 `mvn -pl file -am test`,并按接口影响验证上传、预览、无权限和非法文件场景。
|
||||
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而上传返回 500。
|
||||
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
|
||||
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
|
||||
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
|
||||
- 迁移历史 `/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);
|
||||
|
||||
@PostMapping("/platform-qualifications")
|
||||
ApiResponse<QualificationResponse> submitPlatform(
|
||||
ApiResponse<PlatformQualificationResponse> submitPlatform(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody PlatformQualificationRequest request);
|
||||
|
||||
record QualificationRequest(Long tenantId, String qualificationType,
|
||||
String certificateNo, String documentUrl) { }
|
||||
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
|
||||
record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
|
||||
String replacedDocumentUrl) { }
|
||||
record PlatformQualificationRequest(Long tenantId, String qualificationType,
|
||||
String certificateNo, String documentUrl,
|
||||
java.time.LocalDate validTo) { }
|
||||
java.time.LocalDate validTo, boolean replace) { }
|
||||
}
|
||||
|
||||
+26
-9
@@ -7,6 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.file.client.IamQualificationClient;
|
||||
import com.baiyue.file.service.LocalQualificationStorage;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -27,6 +29,7 @@ import java.time.LocalDate;
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/files/qualifications")
|
||||
public class PlatformQualificationFileController {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class);
|
||||
@Resource private LocalQualificationStorage storage;
|
||||
@Resource private IamQualificationClient iamClient;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@@ -39,30 +42,44 @@ public class PlatformQualificationFileController {
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestParam String qualificationType,
|
||||
@RequestParam(required = false) String certificateNo,
|
||||
@RequestParam(required = false) LocalDate validTo) {
|
||||
@RequestParam(required = false) LocalDate validTo,
|
||||
@RequestParam(defaultValue = "false") boolean replace) {
|
||||
requirePlatform(user);
|
||||
var stored = storage.store(tenantId, file);
|
||||
try {
|
||||
var response = iamClient.submitPlatform(internalToken,
|
||||
new IamQualificationClient.PlatformQualificationRequest(
|
||||
tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
String message = response == null || response.message() == null || response.message().isBlank()
|
||||
? "IAM qualification service is unavailable" : response.message();
|
||||
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()), message);
|
||||
tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo, replace));
|
||||
if (response == null) {
|
||||
throw new IllegalStateException("IAM qualification response is empty");
|
||||
}
|
||||
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();
|
||||
if (!stored.documentUrl().equals(data.replacedDocumentUrl())) {
|
||||
storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl());
|
||||
}
|
||||
return ApiResponse.success(new QualificationFileController.UploadResponse(
|
||||
data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl()));
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
storage.delete(stored);
|
||||
if (exception instanceof BizException business) throw business;
|
||||
LOGGER.error("IAM 平台资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}",
|
||||
tenantId, stored.documentUrl(), exception);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{fileName}")
|
||||
@PreAuthorize("hasAuthority('platform:customer:review')")
|
||||
@PreAuthorize("hasAuthority('platform:customer:view')")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> download(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId, @PathVariable String fileName) {
|
||||
|
||||
@@ -39,24 +39,26 @@ public class QualificationFileController {
|
||||
new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
|
||||
certificateNo, stored.documentUrl()));
|
||||
if (response == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed");
|
||||
throw new IllegalStateException("IAM qualification response is empty");
|
||||
}
|
||||
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 BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed");
|
||||
throw new IllegalStateException("IAM qualification response data is empty");
|
||||
}
|
||||
var data = response.data();
|
||||
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
|
||||
data.tenantStatus(), stored.documentUrl()));
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
storage.delete(stored);
|
||||
if (exception instanceof BizException business) throw business;
|
||||
LOGGER.error("IAM 资质登记调用失败", exception);
|
||||
LOGGER.error("IAM 资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}",
|
||||
tenantId, stored.documentUrl(), exception);
|
||||
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) { }
|
||||
}
|
||||
|
||||
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) {
|
||||
Path directory = qualificationRoot.resolve(Long.toString(tenantId)).normalize();
|
||||
if (!directory.startsWith(qualificationRoot)) {
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
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.Test;
|
||||
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.RequestMapping;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
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.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("企业资质文件接口契约功能")
|
||||
class QualificationFileContractTest {
|
||||
@@ -39,4 +60,139 @@ class QualificationFileContractTest {
|
||||
.anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals);
|
||||
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 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.assertTrue;
|
||||
|
||||
@@ -63,6 +64,30 @@ class LocalQualificationStorageTest {
|
||||
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
|
||||
void legacySeedPathCannotBePretendedToBeAStoredFile() {
|
||||
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||
|
||||
+4
-3
@@ -17,15 +17,16 @@
|
||||
| 履约与分配编排 | `service/FulfillmentTaskService.java` |
|
||||
| 任务与历史持久化 | `entity/`、`mapper/`、`repository/FulfillmentTaskRepository.java` |
|
||||
| 表结构与查询索引迁移 | `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` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 任务状态与订单状态保持领域分离;需要联动时使用稳定 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`。
|
||||
- 当前模块没有专属自动化测试时,不要为小改动擅自引入新框架;至少运行模块构建并记录接口验证。
|
||||
- 修改任务查询、状态或分配逻辑时同步扩展 Service 测试和 Controller 契约测试,不以模块构建代替行为验证。
|
||||
- 从父目录运行 `mvn -pl firstleg -am test`。
|
||||
- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
|
||||
- 新增服务路由时同步检查本地与 Nacos 配置、访问域、公开路径和 Swagger 聚合;前端只访问网关。
|
||||
- 禁止信任客户端自行传入的身份头;Token Claim、Redis Key 变化要与 `auth/`、`common-security/` 同步。
|
||||
- 普通业务响应由下游服务返回 HTTP 200 + body code;Gateway 自身的认证/会话失败仍保留真实 401/403,不能为了统一响应改成 HTTP 200。
|
||||
- CORS 生产来源必须为明确 HTTPS 域名,不能放宽为 `*`。
|
||||
- 从父目录运行 `mvn -pl gateway -am test`。
|
||||
|
||||
+11
-3
@@ -16,8 +16,9 @@
|
||||
| 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` |
|
||||
| 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` |
|
||||
| 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 |
|
||||
| 平台统一用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;统一维护平台与企业租户账号,选项接口返回租户和角色 |
|
||||
| 平台内部用户 | `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` |
|
||||
| Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.java` |
|
||||
| 当前登录成员有效权限 | `controller/AuthorizationController.java` | `AuthorizationSnapshotService.currentPermissions`;实时按有效成员、角色和权限资源计算,不依赖角色管理权限 |
|
||||
@@ -25,17 +26,23 @@
|
||||
| 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 |
|
||||
| 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 |
|
||||
| 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 只能由审核流程变更,不得由启停接口绕过。
|
||||
- 平台代客户上传资质的公开 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`。
|
||||
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
|
||||
|
||||
- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。
|
||||
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
|
||||
- `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。
|
||||
- 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。
|
||||
- 平台企业客户分页仅查询商户租户;分页参数类型错误使用 body code `A0400`,Bean Validation 范围或状态约束错误使用 `A0422`,两者普通业务响应均为 HTTP 200。
|
||||
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
|
||||
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
|
||||
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
|
||||
@@ -43,4 +50,5 @@
|
||||
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。
|
||||
- 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。
|
||||
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
|
||||
- 从父目录运行 `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.QualificationSubmissionResponse;
|
||||
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.ProfileResourceService;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
@@ -50,7 +51,7 @@ public class InternalTenantController {
|
||||
}
|
||||
|
||||
@PostMapping("/platform-qualifications")
|
||||
public ApiResponse<QualificationSubmissionResponse> registerPlatformQualification(
|
||||
public ApiResponse<PlatformQualificationRegistrationResponse> registerPlatformQualification(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody PlatformQualificationRegistrationRequest request) {
|
||||
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,
|
||||
String certificateNo,
|
||||
@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) { }
|
||||
|
||||
@@ -34,6 +34,12 @@ public class ProfileResourceRepository {
|
||||
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<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) {
|
||||
return qualificationMapper.selectCount(new LambdaQueryWrapper<IamTenantQualification>()
|
||||
.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.QualificationSubmissionResponse;
|
||||
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.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamTenantAddress;
|
||||
@@ -92,7 +93,7 @@ public class ProfileResourceService {
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
|
||||
resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false)
|
||||
public QualificationSubmissionResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) {
|
||||
public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) {
|
||||
IamTenant tenant = tenantRepository.findTenant(request.tenantId());
|
||||
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||
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())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
if (repository.findQualifications(tenant.getId()).stream().anyMatch(item ->
|
||||
request.qualificationType().equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus()))) {
|
||||
IamTenantQualification existing = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||
.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 / 该类资质已上传");
|
||||
}
|
||||
String replacedDocumentUrl = null;
|
||||
if (existing != null) {
|
||||
replacedDocumentUrl = existing.getDocumentUrl();
|
||||
existing.setStatus("REJECTED");
|
||||
existing.setReviewComment("平台在送审前替换文件");
|
||||
repository.saveQualification(existing);
|
||||
}
|
||||
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
|
||||
request.certificateNo(), request.documentUrl());
|
||||
qualification.setValidTo(request.validTo());
|
||||
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) {
|
||||
|
||||
+32
-2
@@ -15,6 +15,7 @@ import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -29,11 +30,11 @@ class PlatformQualificationRegistrationTest {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
|
||||
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(
|
||||
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);
|
||||
verify(repository).insertQualification(captor.capture());
|
||||
@@ -42,5 +43,34 @@ class PlatformQualificationRegistrationTest {
|
||||
assertEquals(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo());
|
||||
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
|
||||
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
@@ -11,10 +11,11 @@
|
||||
|---|---|
|
||||
| 商户订单创建、编辑、列表、详情、提交、取消 | `controller/MerchantFirstLegOrderController.java`;列表校验 `merchant:order:list`,详情校验 `merchant:order:view` |
|
||||
| 平台订单列表、详情、审核、接单、开始、完成、取消 | `controller/PlatformFirstLegOrderController.java`;列表和详情校验 `platform:order:list` |
|
||||
| 平台批量客户运单数 | `PlatformFirstLegOrderController.java` 的 `GET /tenant-counts`、`service/OrderApplicationService.countByTenantIds`、`repository/OrderRepository.countByTenantIds`、`dto/TenantOrderCountResponse.java` |
|
||||
| 内部备注与快递信息 | `PlatformFirstLegOrderController.java` |
|
||||
| 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.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` |
|
||||
| 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` |
|
||||
| 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` |
|
||||
@@ -27,12 +28,15 @@
|
||||
- 所有商户查询和写入必须执行租户/访问范围校验;平台操作必须验证权限及合法状态迁移。
|
||||
- 查询接口同样必须校验权限码;沿用 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。
|
||||
- Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。
|
||||
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
|
||||
- 创建草稿和提交订单必须接收 `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` 或对应一期回归。
|
||||
- 普通业务失败使用 HTTP 200 + body code;Feign 收到 HTTP 非 200 代表鉴权、协议或基础设施异常,应归一为 `S0503`,不能用 Feign 状态码推断 404/409 业务语义。
|
||||
|
||||
## 平台代客接口补充
|
||||
|
||||
|
||||
+8
-4
@@ -14,14 +14,14 @@
|
||||
| 内部仓库有效性校验 | `controller/InternalWarehouseController.java` |
|
||||
| 商户往来单位候选与详情 | `controller/MerchantPartnerController.java` |
|
||||
| 内部往来单位有效性校验 | `controller/InternalPartnerController.java` |
|
||||
| 平台往来单位增改查与启停 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java` |
|
||||
| 平台快递公司增改查与启停 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java` |
|
||||
| 平台往来单位增改查、启停与 XLSX 导入 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java`;模板 `/import-template`,导入 `/import` |
|
||||
| 平台快递公司增改查、启停与 XLSX 导入 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java`、`ExpressCompanyImportService.java`;模板 `/import-template`,导入 `/import` |
|
||||
| 服务与数据库结构状态 | `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/` |
|
||||
| 仓库、地址、能力、往来单位与快递公司持久化 | `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/test/java/com/baiyue/warehouse/` |
|
||||
|
||||
@@ -30,8 +30,12 @@
|
||||
- 商户只看到可用且符合租户/服务范围的数据;平台启停必须检查业务约束,不能只改状态字段。
|
||||
- 平台仓库管理接口必须同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。
|
||||
- 往来单位与仓库查询供 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 与种子数据必须同步;历史订单不随主数据更新。
|
||||
- 既有数据库发布必须按 `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`。
|
||||
- Warehouse 的数据库访问异常和 readiness/结构状态探针都保留真实 HTTP 503;其他由公共异常处理器捕获的业务失败才使用 HTTP 200 + body code。
|
||||
- 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。
|
||||
|
||||
Reference in New Issue
Block a user