diff --git a/AGENTS.md b/AGENTS.md index 2cc520267..771987cc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 -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`。 - 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。 ## 本地内部服务令牌 diff --git a/audit-service/AGENTS.md b/audit-service/AGENTS.md index ad60fe04b..4d6ce3fc4 100644 --- a/audit-service/AGENTS.md +++ b/audit-service/AGENTS.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`,并按改动验证接收和持久化结果。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index f550774e8..53b00c291 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -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/` 回归。 diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index e2e5b3bcd..56599c468 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -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`。 diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index df55ecf68..a98583deb 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -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 序列化保持一致。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index bddd9f83c..666aab4ce 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -20,5 +20,6 @@ ## 修改与验证 - Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。 +- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index c60045455..c19ff581f 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh index 9556f8234..c39da79b0 100644 --- a/deploy/scripts/initialize-test-databases.sh +++ b/deploy/scripts/initialize-test-databases.sh @@ -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 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 387ea82f7..f54206504 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -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`。 diff --git a/file/AGENTS.md b/file/AGENTS.md index eb87487db..af6b01f9c 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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 已存元数据、平台下载路由和迁移脚本。 diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java index 46a1e3656..458b8b1b2 100644 --- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java +++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java @@ -15,14 +15,16 @@ public interface IamQualificationClient { @RequestBody QualificationRequest request); @PostMapping("/platform-qualifications") - ApiResponse submitPlatform( + ApiResponse 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) { } } diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 00b1cb8d9..fc62ba24c 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -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 download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable String fileName) { diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index 9f9cc440e..7d431b72a 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -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"); } } diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index fa15642ac..a92add9a1 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -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)) { diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 4bdb9bfde..9d76a85f9 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -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()); + } } diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 3c628970b..939f90b43 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -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()); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 0dd49e293..a6eb59d3b 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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`,为任务列表、按订单查询和分配历史分页补齐索引。 diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index e488aa234..9733d6cad 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -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`。 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index cc093bf70..27b63cf41 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -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 回归。 diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index dd768a2e1..b0102f75e 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -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 registerPlatformQualification( + public ApiResponse registerPlatformQualification( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PlatformQualificationRegistrationRequest request) { verify(token); diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index a92559740..399a1e8e8 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -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) { } diff --git a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java index 043235c80..1a4f40fdd 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/ProfileResourceRepository.java @@ -34,6 +34,12 @@ public class ProfileResourceRepository { public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); } public List findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper().eq(IamTenantAddress::getTenantId, tenantId)); } public List findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper().eq(IamTenantQualification::getTenantId, tenantId)); } + public List findQualificationsForUpdate(long tenantId) { + return qualificationMapper.selectList(new LambdaQueryWrapper() + .eq(IamTenantQualification::getTenantId, tenantId) + .orderByAsc(IamTenantQualification::getId) + .last("FOR UPDATE")); + } public long countApprovedQualifications(long tenantId) { return qualificationMapper.selectCount(new LambdaQueryWrapper() .eq(IamTenantQualification::getTenantId, tenantId) diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index 0339c0386..f27869347 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -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) { diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java index 6112d3b9c..d08e8909d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -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 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 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()); } } diff --git a/order/AGENTS.md b/order/AGENTS.md index 041a5c3d2..21a5cf53b 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -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 业务语义。 ## 平台代客接口补充 diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index bb0db74b8..5f2f5b830 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -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`。