From 7a19e2f096d3278701fda433680f5f14ed006d9a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 31 Jul 2026 14:55:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +- common-web/AGENTS.md | 1 + .../common/web/GlobalExceptionHandler.java | 4 +- .../web/GlobalExceptionHandlerTest.java | 21 ++++ docs/database-schema.md | 1 + docs/phase1-local-api.md | 35 +++++- firstleg/AGENTS.md | 10 +- ...atformFulfillmentAssignmentController.java | 90 ++++++++++++-- .../repository/FulfillmentTaskRepository.java | 29 +++++ .../service/FulfillmentTaskService.java | 42 ++++++- firstleg/src/main/resources/db/firstleg.sql | 1 + iam/AGENTS.md | 4 + .../controller/PlatformTenantController.java | 12 +- .../iam/mapper/AuthorizationQueryMapper.java | 2 +- .../service/AuthorizationSnapshotService.java | 36 +++++- .../main/resources/db/phase1-admin-rbac.sql | 14 ++- iam/src/main/resources/openapi/iam-v1.yaml | 94 ++++++++++++++- .../AuthorizationSnapshotServiceTest.java | 38 ++++++ order/AGENTS.md | 3 +- .../MerchantFirstLegOrderController.java | 21 +++- .../order/repository/OrderRepository.java | 22 +++- .../service/OrderApplicationService.java | 110 +++++++++++++++++- .../order/service/OrderMasterDataGuard.java | 12 +- order/src/main/resources/db/order.sql | 18 +++ .../service/OrderMasterDataGuardTest.java | 10 ++ warehouse/AGENTS.md | 8 +- .../repository/PartnerRepository.java | 39 +++++++ .../repository/PartnerRepositoryTest.java | 17 +++ 28 files changed, 655 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a23b2ecef..959c2a9a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,9 @@ | `gateway/` | 路由、CORS、入口安全、访问域隔离 | `config/`、`filter/`、`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`、`README-ORDER.md` | -| `firstleg/` | 履约任务、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java` | -| `warehouse/` | 仓库、地址、服务能力与往来单位 | `controller/`、`WarehouseApplicationService.java`、`PartnerQueryService.java` | +| `order/` | 订单主数据、持久化幂等、状态编排、快照与 Outbox | `controller/`、`OrderApplicationService.java`、`OrderIdempotencyRecordEntity.java`、`README-ORDER.md` | +| `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` | +| `warehouse/` | 仓库、地址、服务能力、往来单位与快递公司主数据 | `controller/`、`WarehouseApplicationService.java`、`PlatformPartnerQueryService.java`、`ExpressCompanyQueryService.java` | | `file/` | 企业资质文件本地存储、预览与鉴权 | `controller/`、`LocalQualificationStorage.java`;元数据表结构尚未接入 | | `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 | | `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 | @@ -34,6 +34,7 @@ - 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。 - 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue//` 起算;资源路径从模块根目录起算。 - 表结构和增量脚本优先在目标模块 `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/`。 - 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl -am test`。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index ca393fcc5..3a60e32d7 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -18,5 +18,6 @@ ## 修改与验证 - 修改异常响应时同时检查 HTTP 状态、`ApiResponse` 结构、校验错误和安全异常是否仍一致。 +- 查询参数类型转换失败映射为 400,Bean Validation 约束失败映射为 422,禁止落入通用 500。 - 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。 - 从父目录运行 `mvn -pl common-web -am test`。 diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index aaa5095fe..357426898 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -15,6 +15,7 @@ import org.springframework.validation.FieldError; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -54,7 +55,8 @@ public class GlobalExceptionHandler { return validationResponse(errors, request); } - @ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class}) + @ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class, + MethodArgumentTypeMismatchException.class}) public ResponseEntity> handleBadRequest(Exception exception, HttpServletRequest request) { return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request); } diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index ae2d02ae9..f98b3971d 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -4,6 +4,10 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; @@ -20,4 +24,21 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().code()).isEqualTo(ResultCode.BUSINESS_ERROR.code()); assertThat(response.getBody().requestId()).isEqualTo("r1"); } + + @Test + void 查询参数类型错误映射为四百而不是五百() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class); + MethodParameter parameter = new MethodParameter(method, 0); + MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException( + "abc", Long.class, "page", parameter, new NumberFormatException("abc")); + + var response = handler.handleBadRequest(exception, request); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); + } + + private void paging(long page) { + } } diff --git a/docs/database-schema.md b/docs/database-schema.md index eed58a2ad..b6dbf0e91 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -73,6 +73,7 @@ SQL 入口位于各服务的 `src/main/resources/db` 目录。 | `wh_warehouse_address` | 保存仓库运营、退货等不同用途的地址和可选经纬度。 | `tenant_id`、`warehouse_id`、`address_type` | | `wh_service_capability` | 声明仓库可提供的收货、仓储、拣包、退货等服务能力及截单时间。 | `tenant_id`、`warehouse_id`、`capability_code`、`enabled`、`cutoff_time` | | `wh_partner` | 仓储域内的租户往来单位配置,供订单选择客户、联系人和地址;商户候选查询强制过滤停用数据。 | `tenant_id`、`partner_code`、`partner_name`、`partner_type`、`contact_name`、`contact_phone`、`address_line`、`status` | +| `wh_express_company` | 租户维度快递公司主数据,供平台基础资料页分页查询。 | `tenant_id`、`company_code`、`company_name`、`service_phone`、`tracking_url_template`、`status` | ## 文件中心服务 diff --git a/docs/phase1-local-api.md b/docs/phase1-local-api.md index ba7f58e2a..59a8de20d 100644 --- a/docs/phase1-local-api.md +++ b/docs/phase1-local-api.md @@ -9,6 +9,8 @@ Authorization: Bearer Content-Type: application/json ``` +创建订单草稿和提交订单还必须携带 `Idempotency-Key`。同一操作者重试同一请求时复用原值;同一键用于不同请求返回 409,键缺失或长度超过 128 返回 400/422。 + 普通成功响应: ```json @@ -128,6 +130,18 @@ IAM 的商户资质接口只读。旧的 `POST /api/merchant/v1/iam/tenant/quali | POST | `/api/platform/v1/iam/reviews/{id}/approve` | `platform:customer:approve` | 审核通过 | | POST | `/api/platform/v1/iam/reviews/{id}/reject` | `platform:customer:reject` | 审核驳回 | +平台登录初始化与后台管理: + +| 方法 | 路径 | 权限 | 说明 | +|---|---|---|---| +| GET | `/api/{platform\|merchant}/v1/iam/authorization/permissions` | 已认证有效成员 | 实时返回当前有效权限,不要求角色管理权限 | +| GET | `/api/platform/v1/iam/tenants?page=1&size=20` | `platform:customer:list` | 仅分页查询商户企业,支持关键字与状态 | +| GET | `/api/platform/v1/iam/internal-users` | `platform:user:list` | 返回 `membershipId`、账号资料和 `roleIds` | +| POST | `/api/platform/v1/iam/internal-users` | `platform:user:create` | 创建或关联平台认证账号 | +| PUT | `/api/platform/v1/iam/internal-users/{membershipId}` | `platform:user:edit` | 编辑资料与角色 | +| PUT | `/api/platform/v1/iam/internal-users/{membershipId}/status` | `platform:user:status` | 启用或停用平台用户 | +| PUT | `/api/platform/v1/iam/internal-users/{membershipId}/password` | `platform:user:reset-password` | 重置密码,密码只转交 Auth | + 审核请求: ```json @@ -165,11 +179,11 @@ IAM 的商户资质接口只读。旧的 `POST /api/merchant/v1/iam/tenant/quali | 方法 | 路径 | 权限 | 说明 | |---|---|---|---| -| POST | `/api/merchant/v1/orders/first-leg` | `merchant:order:create` | 创建草稿,`data` 为订单 ID | +| POST | `/api/merchant/v1/orders/first-leg` | `merchant:order:create` | 创建草稿,必须携带 `Idempotency-Key`,`data` 为订单 ID | | PUT | `/api/merchant/v1/orders/first-leg/{id}` | `merchant:order:edit` | 编辑草稿 | | GET | `/api/merchant/v1/orders/first-leg` | 商户身份 | 分页查询自己的订单 | | GET | `/api/merchant/v1/orders/first-leg/{id}` | 商户身份 | 查询自己的订单详情 | -| POST | `/api/merchant/v1/orders/first-leg/{id}/submit` | `merchant:order:submit` | 提交订单 | +| POST | `/api/merchant/v1/orders/first-leg/{id}/submit` | `merchant:order:submit` | 提交订单,必须携带 `Idempotency-Key` | 列表参数:`keyword,status,customerId,destinationWarehouseId,createdFrom,createdTo,page,size`;`page` 从 1 开始,`size` 为 1 至 200。 @@ -251,6 +265,21 @@ IAM 的商户资质接口只读。旧的 `POST /api/merchant/v1/iam/tenant/quali | GET | `/api/merchant/v1/partners/candidates` | 当前租户启用往来单位候选 | | GET | `/api/merchant/v1/partners/{partnerId}` | 当前租户启用往来单位详情 | | GET | `/api/platform/v1/warehouse-admin?tenantId={tenantId}&page=1&size=20` | 平台仓储列表 | +| GET | `/api/platform/v1/partners?tenantId={tenantId}&page=1&size=20` | 平台往来单位分页,需要 `platform:counterparty:list` | +| GET | `/api/platform/v1/partners/{id}?tenantId={tenantId}` | 平台往来单位详情,需要 `platform:counterparty:list` | +| POST | `/api/platform/v1/partners?tenantId={tenantId}` | 新增往来单位,需要 `platform:counterparty:create` | +| PUT | `/api/platform/v1/partners/{id}?tenantId={tenantId}` | 编辑往来单位,需要 `platform:counterparty:edit` | +| PATCH | `/api/platform/v1/partners/{id}/status?tenantId={tenantId}` | 启用或停用往来单位,需要 `platform:counterparty:status` | +| GET | `/api/platform/v1/express-companies?tenantId={tenantId}&page=1&size=20` | 平台快递公司分页,需要 `platform:express:list` | +| GET | `/api/platform/v1/express-companies/{id}?tenantId={tenantId}` | 平台快递公司详情,需要 `platform:express:list` | +| POST | `/api/platform/v1/express-companies?tenantId={tenantId}` | 新增快递公司,需要 `platform:express:create` | +| PUT | `/api/platform/v1/express-companies/{id}?tenantId={tenantId}` | 编辑快递公司,需要 `platform:express:edit` | +| PATCH | `/api/platform/v1/express-companies/{id}/status?tenantId={tenantId}` | 启用或停用快递公司,需要 `platform:express:status` | +| GET | `/api/platform/v1/first-leg/tasks?tenantId={tenantId}&page=1&size=20` | 履约任务分页,需要 `platform:firstleg:task:list` | +| GET | `/api/platform/v1/first-leg/tasks/{taskId}?tenantId={tenantId}` | 履约任务详情,需要 `platform:firstleg:task:view` | +| GET | `/api/platform/v1/first-leg/tasks/by-order/{orderId}?tenantId={tenantId}` | 按订单查询履约任务,需要 `platform:firstleg:task:view` | +| GET | `/api/platform/v1/first-leg/tasks/{taskId}/assignment-history?tenantId={tenantId}` | 分配历史,需要 `platform:firstleg:assignment:history` | +| POST | `/api/platform/v1/first-leg/tasks/{taskId}/assignment?tenantId={tenantId}` | 分配或改派,需要 `platform:firstleg:assignment:assign` | | GET | `/api/platform/v1/iam/status` | IAM 网关路由状态 | | GET | `/api/platform/v1/orders/status` | Order 网关路由状态 | | GET | `/api/platform/v1/first-leg/status` | Firstleg 网关路由状态 | @@ -260,6 +289,8 @@ IAM 的商户资质接口只读。旧的 `POST /api/merchant/v1/iam/tenant/quali 往来单位由 `warehouse-service` 承载,表为 `baiyue_warehouse.wh_partner`,不是独立服务或独立数据库。仓库和往来单位查询强制执行租户隔离和停用过滤。 +部署到既有数据库时,除主建表脚本外还要执行 `firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql`;该脚本可重复执行,用于补齐履约任务租户时间分页索引。 + 平台履约可通过 `PUT /api/platform/v1/orders/first-leg/{orderId}/express?tenantId={tenantId}` 写入 `expressCompany,expressTrackingNo`;商户端只能在订单详情读取,不能维护快递公司或调用平台写接口。 ## 8. Swagger 分组 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 4438d9516..d6dbeb105 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -10,17 +10,21 @@ | 功能 | 入口 | |---|---| | 应用入口 | `src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java` | -| 平台履约分配 API | `controller/PlatformFulfillmentAssignmentController.java`(`/api/platform/v1/first-leg/tasks`) | +| 平台履约任务查询与分配 API | `controller/PlatformFulfillmentAssignmentController.java`(`/api/platform/v1/first-leg/tasks`) | +| 任务列表、详情、按订单查询、分配历史 | `FulfillmentTaskService.java`、`FulfillmentTaskRepository.java`;只读权限码见控制器 `@PreAuthorize` | | 服务状态 | `controller/FirstlegHealthController.java` | -| 分配请求 | `dto/AssignRequest.java` | +| 查询与分配请求 | `dto/FulfillmentTaskQuery.java`、`dto/AssignRequest.java` | | 履约与分配编排 | `service/FulfillmentTaskService.java` | | 任务与历史持久化 | `entity/`、`mapper/`、`repository/FulfillmentTaskRepository.java` | -| 表结构 | `src/main/resources/db/firstleg.sql` | +| 表结构与查询索引迁移 | `src/main/resources/db/firstleg.sql`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` | +| OpenAPI 与测试 | `src/main/resources/openapi/firstleg-v1.yaml`、`src/test/java/com/baiyue/firstleg/` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/firstleg-service.yml` | ## 修改与验证 - 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。 - 分配操作要保留历史、操作者、时间和幂等语义;平台端接口必须执行权限校验。 +- 所有查询必须带目标租户并在仓储层过滤;列表和分配历史使用统一分页响应,详情不存在时统一返回 404。 +- 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。 - 当前模块没有专属自动化测试时,不要为小改动擅自引入新框架;至少运行模块构建并记录接口验证。 - 从父目录运行 `mvn -pl firstleg -am test`。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java index c70dac8c9..7f8289f2b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFulfillmentAssignmentController.java @@ -2,10 +2,14 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.AssignRequest; +import com.baiyue.firstleg.dto.FulfillmentTaskQuery; +import com.baiyue.firstleg.entity.AssignmentHistoryEntity; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.service.FulfillmentTaskService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -13,7 +17,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -22,11 +30,11 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * 平台端头程履约任务指派接口,在目标租户内完成任务指派或改派并记录指派历史。 + * 平台端头程履约任务接口,在目标租户内提供任务查询、分配和分配历史查询。 */ @Tag( - name = "Platform first-leg assignment / 平台头程指派", - description = "Platform APIs for assigning tenant-scoped first-leg fulfillment tasks. / 平台端按租户指派头程履约任务的接口。" + name = "平台头程履约任务", + description = "平台端按目标租户查询和分配头程履约任务。" ) @RestController @RequestMapping("/api/platform/v1/first-leg/tasks") @@ -52,6 +60,7 @@ public class PlatformFulfillmentAssignmentController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Task is closed or concurrently updated / 任务已关闭或发生并发更新") }) @PostMapping("/{taskId}/assignment") + @PreAuthorize("hasAuthority('platform:firstleg:assignment:assign')") public ApiResponse assign( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -72,17 +81,84 @@ public class PlatformFulfillmentAssignmentController { return ApiResponse.success(null); } + @Operation(summary = "查询履约任务列表", description = "在指定租户内按条件分页查询头程履约任务。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无查询权限") + }) + @GetMapping + @PreAuthorize("hasAuthority('platform:firstleg:task:list')") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @Valid FulfillmentTaskQuery query) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.page(tenantId, query)); + } + + @Operation(summary = "查询履约任务详情", description = "查询指定租户内的头程履约任务详情。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无查看权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在") + }) + @GetMapping("/{taskId}") + @PreAuthorize("hasAuthority('platform:firstleg:task:view')") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long taskId) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.detail(tenantId, taskId)); + } + + @Operation(summary = "按订单查询履约任务", description = "使用订单逻辑ID查询指定租户内唯一的履约任务。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无查看权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在") + }) + @GetMapping("/by-order/{orderId}") + @PreAuthorize("hasAuthority('platform:firstleg:task:view')") + public ApiResponse byOrder( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long orderId) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.byOrder(tenantId, orderId)); + } + + @Operation(summary = "查询分配历史", description = "分页查询指定履约任务不可变的分配历史。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无历史查看权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "任务不存在") + }) + @GetMapping("/{taskId}/assignment-history") + @PreAuthorize("hasAuthority('platform:firstleg:assignment:history')") + public ApiResponse> assignmentHistory( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long taskId, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.assignmentHistory(tenantId, taskId, page, size)); + } + private static void requirePlatform(AuthenticatedUser user) { if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, - "Platform application required / 仅允许平台端访问"); + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } } private static void requireTenant(long tenantId) { if (tenantId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, - "Target tenant is required / 必须指定有效目标租户"); + throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java index 89768edef..5f343ad61 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java @@ -1,11 +1,15 @@ package com.baiyue.firstleg.repository; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.FulfillmentTaskQuery; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.entity.AssignmentHistoryEntity; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.mapper.AssignmentHistoryMapper; import com.baiyue.firstleg.mapper.FulfillmentTaskMapper; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; /** * 头程履约任务仓储,按租户查询和更新任务,并持久化每次指派历史。 @@ -26,6 +30,31 @@ public class FulfillmentTaskRepository { .eq(FulfillmentTaskEntity::getId, taskId)); } + public FulfillmentTaskEntity findByOrder(long tenantId, long orderId) { + return tasks.selectOne(new LambdaQueryWrapper() + .eq(FulfillmentTaskEntity::getTenantId, tenantId) + .eq(FulfillmentTaskEntity::getOrderId, orderId)); + } + + public PageResult page(long tenantId, FulfillmentTaskQuery query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(FulfillmentTaskEntity::getTenantId, tenantId) + .eq(StringUtils.hasText(query.status()), FulfillmentTaskEntity::getStatus, query.status()) + .eq(query.assigneeId() != null, FulfillmentTaskEntity::getAssigneeId, query.assigneeId()) + .like(StringUtils.hasText(query.keyword()), FulfillmentTaskEntity::getOrderNo, query.keyword()) + .orderByDesc(FulfillmentTaskEntity::getCreatedAt); + return PageResult.from(tasks.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public PageResult pageHistory( + long tenantId, long taskId, long page, long size) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(AssignmentHistoryEntity::getTenantId, tenantId) + .eq(AssignmentHistoryEntity::getTaskId, taskId) + .orderByDesc(AssignmentHistoryEntity::getCreatedAt); + return PageResult.from(histories.selectPage(new Page<>(page, size), wrapper)); + } + public boolean update(FulfillmentTaskEntity task) { return tasks.updateById(task) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskService.java index 0118ac8a8..cc3f47522 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskService.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.service; import jakarta.annotation.Resource; import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.FulfillmentTaskQuery; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.AssignmentHistoryEntity; import com.baiyue.firstleg.entity.FulfillmentTaskEntity; @@ -23,11 +25,7 @@ public class FulfillmentTaskService { @Transactional public void assign(long tenantId, long operatorId, long taskId, long assigneeId, String assigneeName, String reason) { - FulfillmentTaskEntity task = repository.findOwned(tenantId, taskId); - if (task == null) { - throw new BizException(ResultCode.NOT_FOUND, - "Fulfillment task not found / 头程履约任务不存在"); - } + FulfillmentTaskEntity task = requireTask(tenantId, taskId); if ("COMPLETED".equals(task.getStatus()) || "CANCELLED".equals(task.getStatus())) { throw new BizException(ResultCode.CONFLICT, "Closed task cannot be assigned / 已关闭任务不能再次指派"); @@ -55,4 +53,38 @@ public class FulfillmentTaskService { history.setCreatedAt(LocalDateTime.now()); repository.insertHistory(history); } + + public PageResult page(long tenantId, FulfillmentTaskQuery query) { + return repository.page(tenantId, query); + } + + public FulfillmentTaskEntity detail(long tenantId, long taskId) { + return requireTask(tenantId, taskId); + } + + public FulfillmentTaskEntity byOrder(long tenantId, long orderId) { + FulfillmentTaskEntity task = repository.findByOrder(tenantId, orderId); + if (task == null) { + throw notFound(); + } + return task; + } + + public PageResult assignmentHistory( + long tenantId, long taskId, long page, long size) { + requireTask(tenantId, taskId); + return repository.pageHistory(tenantId, taskId, page, size); + } + + private FulfillmentTaskEntity requireTask(long tenantId, long taskId) { + FulfillmentTaskEntity task = repository.findOwned(tenantId, taskId); + if (task == null) { + throw notFound(); + } + return task; + } + + private BizException notFound() { + return new BizException(ResultCode.NOT_FOUND, "头程履约任务不存在"); + } } diff --git a/firstleg/src/main/resources/db/firstleg.sql b/firstleg/src/main/resources/db/firstleg.sql index 9a042f19f..3952303c4 100644 --- a/firstleg/src/main/resources/db/firstleg.sql +++ b/firstleg/src/main/resources/db/firstleg.sql @@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS fl_fulfillment_task ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_task_tenant_order (tenant_id, order_id), + KEY idx_task_tenant_created (tenant_id, created_at), KEY idx_task_assignee_status (tenant_id, assignee_id, status, created_at) ) ENGINE=InnoDB COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'; diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 17ae068cd..c78c4af09 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -14,10 +14,12 @@ | 商户个人与企业资料 | `controller/MerchantTenantController.java` | `PersonalProfileService.java`、`TenantService.java` | | 商户成员增改、启停、重置密码 | `MerchantTenantController.java` | `MemberService.java`,账号协作看 `client/AuthAccountClient.java` | | 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` | +| 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` | | 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 | | 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java` | | 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` | | Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.java` | +| 当前登录成员有效权限 | `controller/AuthorizationController.java` | `AuthorizationSnapshotService.currentPermissions`;实时按有效成员、角色和权限资源计算,不依赖角色管理权限 | | DTO | `src/main/java/com/baiyue/iam/dto/` | 按 Authorization、Member、Onboarding、Profile、Rbac、Tenant 分类 | | 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 | | 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 | @@ -28,6 +30,8 @@ - `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。 - 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。 +- `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。 +- 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。 - 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。 - 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。 - SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。 diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index e0b10c8fa..0839545b9 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -16,6 +16,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.access.prepost.PreAuthorize; @@ -39,15 +40,18 @@ public class PlatformTenantController { @PreAuthorize("hasAuthority('platform:customer:list')") @Operation(summary = "List enterprise tenants / 查询企业列表", description = "Returns paged merchant enterprise tenants for platform operators. / 平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Tenant list returned / 企业列表查询成功"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid paging parameters / 分页参数无效"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "422", description = "分页范围或状态无效") }) public ApiResponse> page( @RequestParam(defaultValue = "1") @Min(1) long page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, @RequestParam(required = false) String keyword, - @RequestParam(required = false) String status, + @RequestParam(required = false) + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pageMerchants(page, size, keyword, status)); diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index d39c7807b..1a65a7d07 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -55,7 +55,7 @@ public interface AuthorizationQueryMapper { AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) - AND p.app_code = #{appCode} AND p.status = 'ACTIVE' + AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 ORDER BY p.permission_code """) List findPermissions(@Param("userId") long userId, diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index e8931d5d0..c90ffec80 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse; import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import com.baiyue.common.security.AuthenticatedUser; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; /** @@ -24,6 +26,7 @@ public class AuthorizationSnapshotService { @Transactional(readOnly = true) public SnapshotResponse load(SnapshotRequest request) { + requireRequest(request); String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT); if (!APPS.contains(appCode)) { throw new BizException(ResultCode.FORBIDDEN, "未知的应用身份域"); @@ -38,11 +41,40 @@ public class AuthorizationSnapshotService { throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效"); } - Set roles = new LinkedHashSet<>(mapper.findRoles( + Set roles = normalizedSet(mapper.findRoles( request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); - Set permissions = new LinkedHashSet<>(mapper.findPermissions( + Set permissions = normalizedSet(mapper.findPermissions( request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions)); } + + /** + * 根据已认证请求主体重新计算有效权限,而不是复用 JWT 中可能已经过期的权限声明。 + */ + @Transactional(readOnly = true) + public Set currentPermissions(AuthenticatedUser user, String appCode) { + if (user == null || user.userId() == null || user.tenantId() == null || user.membershipId() == null) { + throw new BizException(ResultCode.UNAUTHORIZED, "Authentication identity is incomplete / 登录身份不完整"); + } + return load(new SnapshotRequest(user.userId(), user.tenantId(), user.membershipId(), appCode)) + .getPermissions(); + } + + private void requireRequest(SnapshotRequest request) { + if (request == null || request.getUserId() == null || request.getTenantId() == null + || request.getMembershipId() == null || request.getAppCode() == null + || request.getAppCode().isBlank()) { + throw new BizException(ResultCode.FORBIDDEN, "Authorization identity is incomplete / 授权身份不完整"); + } + } + + private Set normalizedSet(List values) { + if (values == null || values.isEmpty()) { + return Set.of(); + } + LinkedHashSet normalized = new LinkedHashSet<>(); + values.stream().filter(java.util.Objects::nonNull).forEach(normalized::add); + return Set.copyOf(normalized); + } } diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 53dad1952..19fc30c2a 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -84,6 +84,7 @@ VALUES (5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0), (5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0), (5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 0), (5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), @@ -103,9 +104,18 @@ VALUES (5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0), (5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0), (5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0), + (5102021, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:task:view', '查看履约任务', NULL, NULL, 10, 'ACTIVE', 0), + (5102022, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:history', '查看分配历史', NULL, NULL, 20, 'ACTIVE', 0), + (5102023, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:assign', '分配履约任务', NULL, NULL, 30, 'ACTIVE', 0), (5103011, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:create', '新增仓库', NULL, NULL, 10, 'ACTIVE', 0), (5103012, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:edit', '编辑仓库', NULL, NULL, 20, 'ACTIVE', 0), - (5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0) + (5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0), + (5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 0), + (5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 0), + (5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启用停用快递公司', NULL, NULL, 30, 'ACTIVE', 0), + (5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 0), + (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0), + (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), @@ -168,7 +178,7 @@ ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE permission_code LIKE 'platform:order%' +WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 00a1d05e9..5147f5d0c 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -18,6 +18,25 @@ tags: description: Platform and merchant role-permission management / 平台端与商户端角色权限管理 paths: /api/platform/v1/iam/tenants: + get: + tags: [Platform Tenant / 平台租户] + summary: List enterprise tenants / 查询企业客户列表 + description: Returns paged merchant tenants. Requires `platform:customer:list`. / 分页查询商户企业客户,需要 `platform:customer:list` 权限。 + parameters: + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, required: false, schema: {type: string}, description: 企业编码、名称、信用代码或联系人关键字} + - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} + responses: + '200': + description: 企业客户分页查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/TenantPageApiResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '422': {$ref: '#/components/responses/UnprocessableEntity'} post: tags: [Platform Tenant / 平台租户] summary: Register enterprise tenant / 创建企业租户 @@ -32,6 +51,32 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /api/platform/v1/iam/authorization/permissions: + get: + tags: [IAM Resources / IAM 资源] + summary: Get current effective permissions / 查询当前用户有效权限 + description: Returns the current active platform membership's permission codes. It only requires a valid PLATFORM JWT and does not require `platform:role:list`. / 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。 + responses: + '200': + description: 当前平台成员权限查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /api/merchant/v1/iam/authorization/permissions: + get: + tags: [IAM Resources / IAM 资源] + summary: Get current effective permissions / 查询当前用户有效权限 + description: Returns the current active merchant membership's permission codes. / 返回当前有效商户成员权限编码。 + responses: + '200': + description: 当前商户成员权限查询成功 + content: + application/json: + schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} /api/platform/v1/iam/reviews: get: tags: [Platform Tenant / 平台租户] @@ -79,7 +124,11 @@ paths: tags: [IAM Resources / IAM 资源] summary: 查询平台内部用户 responses: - '200': {$ref: '#/components/responses/Success'} + '200': + description: 平台内部用户查询成功,公开主键为 membershipId 并回显 roleIds + content: + application/json: + schema: {$ref: '#/components/schemas/InternalUserListApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} post: tags: [IAM Resources / IAM 资源] @@ -326,6 +375,7 @@ components: Forbidden: {description: Permission or tenant scope rejected / 权限或租户范围不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} schemas: ApiResponse: type: object @@ -337,6 +387,48 @@ components: requestId: {type: string} traceId: {type: string} timestamp: {type: string, format: date-time} + PermissionCodesApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: array + uniqueItems: true + items: {type: string} + TenantPageApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/TenantResponse'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + InternalUserListApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {type: array, items: {$ref: '#/components/schemas/InternalUserResponse'}} + TenantResponse: + type: object + required: [id, tenantCode, tenantName, status] + properties: + id: {type: integer, format: int64} + tenantCode: {type: string} + tenantName: {type: string} + status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]} + legalName: {type: string} + unifiedSocialCreditCode: {type: string} + contactName: {type: string} + contactPhone: {type: string} + contactEmail: {type: string, format: email} RegisterRequest: type: object required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone] diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 67fea8c50..b878d69a8 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.iam.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; +import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.mapper.AuthorizationQueryMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -56,6 +57,43 @@ class AuthorizationSnapshotServiceTest { verify(mapper).countActiveIdentity(42L, 7L, 9L); } + @Test + void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() { + AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); + when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR")); + when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")) + .thenReturn(List.of("platform:order:list")); + + var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, + "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); + + assertThat(result).containsExactly("platform:order:list"); + verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM"); + } + + @Test + void rejectsIncompleteCurrentIdentityAsBusinessError() { + assertThatThrownBy(() -> service(mock(AuthorizationQueryMapper.class)) + .currentPermissions(new AuthenticatedUser(42L, 0L, 0L, null, + "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM")) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED)); + } + + @Test + void 空角色和权限查询结果不应触发五百错误() { + AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class); + when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); + when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null); + when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null); + + var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, + "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); + + assertThat(result).isEmpty(); + } + private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) { AuthorizationSnapshotService service = new AuthorizationSnapshotService(); ReflectionTestUtils.setField(service, "mapper", mapper); diff --git a/order/AGENTS.md b/order/AGENTS.md index c6082b29a..0c9988190 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -17,7 +17,7 @@ | 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java` | | 租户与访问范围 | `service/OrderScopeGuard.java` | | 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` | -| 主单、货物、参与方、历史、备注、Outbox | `entity/`、`mapper/`、`repository/OrderRepository.java` | +| 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` | | 表结构与一期扩展 | `src/main/resources/db/order.sql`、`phase1-masterdata-extension.sql`、`phase1-order-extension.sql` | | 模块说明 | `README-ORDER.md` | | 状态、主数据与接口权限测试 | `src/test/java/com/baiyue/order/service/`、`src/test/java/com/baiyue/order/controller/` | @@ -29,4 +29,5 @@ - 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。 - 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。 - 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。 +- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按租户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。 - 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。 diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index 20490f3ca..4eb80c612 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -45,13 +46,18 @@ public class MerchantFirstLegOrderController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft created / 草稿创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或请求仍在处理中"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用") }) @PostMapping @PreAuthorize("hasAuthority('merchant:order:create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true) + @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody OrderRequests.SaveRequest request) { - return ApiResponse.success(service.createDraft(OrderScopeGuard.merchant(user), user.userId(), request)); + return ApiResponse.success(service.createDraft( + OrderScopeGuard.merchant(user), user.userId(), request, idempotencyKey)); } @BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿") @@ -104,15 +110,20 @@ public class MerchantFirstLegOrderController { @Operation(summary = "Submit / 提交", description = "Submits a draft order for platform review and records status history. / 提交订单草稿进入平台审核,并记录状态历史。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order submitted / 订单提交成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "缺少或使用了无效幂等键"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转无效") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转或幂等键冲突"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用") }) @PostMapping("/{id}/submit") @PreAuthorize("hasAuthority('merchant:order:submit')") public ApiResponse submit( @AuthenticationPrincipal AuthenticatedUser user, - @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) { - return transit(user, id, OrderStatus.SUBMITTED, null); + @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id, + @Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true) + @RequestHeader("Idempotency-Key") String idempotencyKey) { + service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, idempotencyKey); + return ApiResponse.success(null); } @BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Cancel order / 取消订单") diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index db7f2731a..320c34588 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -24,10 +24,12 @@ public class OrderRepository { private final OrderStatusHistoryMapper historyMapper; private final OutboxEventMapper outboxMapper; private final OrderChildPurgeMapper childPurgeMapper; + private final OrderIdempotencyRecordMapper idempotencyMapper; public OrderRepository(OrderMapper orderMapper, OrderCargoMapper cargoMapper, OrderPartyMapper partyMapper, OrderInternalNoteMapper noteMapper, OrderStatusHistoryMapper historyMapper, - OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper) { + OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper, + OrderIdempotencyRecordMapper idempotencyMapper) { this.orderMapper = orderMapper; this.cargoMapper = cargoMapper; this.partyMapper = partyMapper; @@ -35,6 +37,7 @@ public class OrderRepository { this.historyMapper = historyMapper; this.outboxMapper = outboxMapper; this.childPurgeMapper = childPurgeMapper; + this.idempotencyMapper = idempotencyMapper; } public void insertOrder(OrderEntity order) { orderMapper.insert(order); } @@ -44,6 +47,23 @@ public class OrderRepository { public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); } public void insertHistory(OrderStatusHistoryEntity history) { historyMapper.insert(history); } public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); } + public void insertIdempotency(OrderIdempotencyRecordEntity record) { idempotencyMapper.insert(record); } + + public OrderIdempotencyRecordEntity findIdempotency( + long tenantId, long operatorId, String operation, String idempotencyKey) { + return idempotencyMapper.selectOne(new LambdaQueryWrapper() + .eq(OrderIdempotencyRecordEntity::getTenantId, tenantId) + .eq(OrderIdempotencyRecordEntity::getOperatorId, operatorId) + .eq(OrderIdempotencyRecordEntity::getOperation, operation) + .eq(OrderIdempotencyRecordEntity::getIdempotencyKey, idempotencyKey)); + } + + public int completeIdempotency(OrderIdempotencyRecordEntity record, Long resultOrderId) { + record.setStatus("COMPLETED"); + record.setResultOrderId(resultOrderId); + record.setCompletedAt(java.time.LocalDateTime.now()); + return idempotencyMapper.updateById(record); + } public OrderEntity findOwned(long tenantId, long orderId) { return orderMapper.selectOne(new LambdaQueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 80f0ca723..61133c992 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -10,11 +10,15 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -26,8 +30,20 @@ public class OrderApplicationService { @Resource private OrderEligibilityGuard eligibilityGuard; @Resource private OrderMasterDataGuard masterDataGuard; + /** 创建草稿时使用数据库唯一键保证请求幂等。 */ @Transactional - public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request) { + public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) { + IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "CREATE_DRAFT", 0L, idempotencyKey, + requestHash("CREATE_DRAFT", request)); + if (idempotency.replayed()) { + return idempotency.record().getResultOrderId(); + } + Long orderId = createDraftInternal(tenantId, userId, request); + completeIdempotency(idempotency, orderId); + return orderId; + } + + private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) { eligibilityGuard.requireApproved(tenantId); validatePrototypeFields(request); var snapshots = masterDataGuard.validate(tenantId, request); @@ -43,10 +59,10 @@ public class OrderApplicationService { @Transactional public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { + OrderEntity order = require(tenantId, id); eligibilityGuard.requireApproved(tenantId); validatePrototypeFields(request); var snapshots = masterDataGuard.validate(tenantId, request); - OrderEntity order = require(tenantId, id); if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) { throw new BizException("Only draft or rejected orders can be edited"); } @@ -58,8 +74,8 @@ public class OrderApplicationService { @Transactional public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) { - if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId); OrderEntity order = require(tenantId, id); + if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId); OrderStatus from = order.getStatus(); if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target); @@ -79,6 +95,18 @@ public class OrderApplicationService { if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target); } + /** 提交订单的幂等入口。 */ + @Transactional + public void submit(long tenantId, long userId, String username, long id, String idempotencyKey) { + IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "SUBMIT_ORDER", id, idempotencyKey, + requestHash("SUBMIT_ORDER", Map.of("orderId", id))); + if (idempotency.replayed()) { + return; + } + transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null); + completeIdempotency(idempotency, id); + } + public PageResult page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } public OrderDetailResponse detail(long tenantId, long id) { OrderEntity order = require(tenantId, id); @@ -149,9 +177,83 @@ public class OrderApplicationService { private OrderEntity require(long tenantId, long id) { OrderEntity order = repository.findOwned(tenantId, id); - if (order == null) throw new BizException("Order not found"); + if (order == null) throw new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND, "Order not found"); return order; } + + private IdempotencyContext beginIdempotency(long tenantId, long operatorId, String operation, long resourceId, + String rawKey, String requestHash) { + if (!StringUtils.hasText(rawKey)) { + throw new BizException(com.baiyue.common.core.api.ResultCode.BAD_REQUEST, + "必须提供 Idempotency-Key"); + } + String key = rawKey.trim(); + if (key.length() > 128) { + throw new BizException(com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, + "Idempotency-Key length must not exceed 128"); + } + OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity(); + record.setId(IdWorker.getId()); + record.setTenantId(tenantId); + record.setOperatorId(operatorId); + record.setOperation(operation); + record.setResourceId(resourceId); + record.setIdempotencyKey(key); + record.setRequestHash(requestHash); + record.setStatus("PROCESSING"); + record.setCreatedAt(LocalDateTime.now()); + record.setUpdatedAt(LocalDateTime.now()); + try { + repository.insertIdempotency(record); + return IdempotencyContext.created(record); + } catch (DuplicateKeyException duplicate) { + OrderIdempotencyRecordEntity existing = repository.findIdempotency( + tenantId, operatorId, operation, key); + if (existing == null || !requestHash.equals(existing.getRequestHash())) { + throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "Idempotency-Key conflicts with a different request"); + } + if (!"COMPLETED".equals(existing.getStatus())) { + throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "The idempotent request is still being processed"); + } + return IdempotencyContext.replayed(existing); + } + } + + private void completeIdempotency(IdempotencyContext context, Long resultOrderId) { + if (context.enabled() && !context.replayed()) { + if (repository.completeIdempotency(context.record(), resultOrderId) != 1) { + throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, + "幂等记录已被并发修改"); + } + } + } + + private String requestHash(String operation, Object request) { + try { + byte[] source = (operation + ":" + json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(source); + StringBuilder result = new StringBuilder(64); + for (byte value : digest) { + result.append(String.format("%02x", value)); + } + return result.toString(); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new BizException(com.baiyue.common.core.api.ResultCode.INTERNAL_ERROR, + "Cannot create idempotency request fingerprint"); + } + } + + private record IdempotencyContext(OrderIdempotencyRecordEntity record, boolean enabled, boolean replayed) { + static IdempotencyContext disabled() { return new IdempotencyContext(null, false, false); } + static IdempotencyContext created(OrderIdempotencyRecordEntity record) { + return new IdempotencyContext(record, true, false); + } + static IdempotencyContext replayed(OrderIdempotencyRecordEntity record) { + return new IdempotencyContext(record, true, true); + } + } private boolean allowed(OrderStatus from, OrderStatus target) { return switch (from) { case DRAFT -> target == OrderStatus.SUBMITTED || target == OrderStatus.CANCELLED; diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 3f6be0b78..d6f6be372 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -40,6 +40,10 @@ public class OrderMasterDataGuard { throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在、已停用或不属于当前企业"); } catch (FeignException exception) { throw unavailable("主数据"); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable("主数据"); } } public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) { @@ -51,7 +55,13 @@ public class OrderMasterDataGuard { if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库"); } catch (FeignException.NotFound exception) { throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库已停用、越权或不存在"); - } catch (FeignException exception) { throw unavailable("主数据"); } + } catch (FeignException exception) { + throw unavailable("主数据"); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable("主数据"); + } } private PartnerClient.Partner loadPartner(long tenantId,long id) { var response=partners.active(token,tenantId,id); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 1f7617b90..848379129 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -161,3 +161,21 @@ CREATE TABLE IF NOT EXISTS ord_outbox_event ( PRIMARY KEY (id), KEY idx_outbox_publish (publish_status, next_retry_at, created_at) ) ENGINE=InnoDB COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志'; + +CREATE TABLE IF NOT EXISTS ord_idempotency_record ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作者ID', + operation VARCHAR(32) NOT NULL COMMENT '幂等操作类型', + resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源ID,创建草稿时为0', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256摘要', + status VARCHAR(16) NOT NULL COMMENT '处理状态:PROCESSING或COMPLETED', + result_order_id BIGINT UNSIGNED NULL COMMENT '成功结果订单ID', + completed_at DATETIME(3) NULL COMMENT '处理完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), + KEY idx_order_idempotency_created (created_at) +) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录:唯一键保证同租户同操作者并发请求只执行一次'; diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index a12b7b99f..06a7ec8f3 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -59,6 +59,16 @@ class OrderMasterDataGuardTest { .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); verifyNoInteractions(warehouses); } + @Test + void 下游非Feign运行时异常映射为服务不可用() { + WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class); + when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败")); + + assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L))) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } private OrderRequests.SaveRequest request(Long partner,Long warehouse) { var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null); var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE, diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index f709f5027..6cae926bf 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -14,11 +14,13 @@ | 内部仓库有效性校验 | `controller/InternalWarehouseController.java` | | 商户往来单位候选与详情 | `controller/MerchantPartnerController.java` | | 内部往来单位有效性校验 | `controller/InternalPartnerController.java` | +| 平台往来单位增改查与启停 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java` | +| 平台快递公司增改查与启停 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java` | | 仓库业务编排 | `service/WarehouseApplicationService.java` | | 往来单位查询与停用过滤 | `service/PartnerQueryService.java` | -| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java` | -| 仓库、地址、能力、伙伴持久化 | `entity/`、`mapper/`、`repository/` | -| 表结构、伙伴与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-seed.sql` | +| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` | +| 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/` | +| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`warehouse-seed.sql` | | 测试 | `src/test/java/com/baiyue/warehouse/` | ## 修改与验证 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java index c23154a26..22a9dcf84 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java @@ -1,6 +1,9 @@ package com.baiyue.warehouse.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.mapper.PartnerMapper; import org.springframework.stereotype.Repository; @@ -24,4 +27,40 @@ public class PartnerRepository { .eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getId, id) .eq(PartnerEntity::getStatus, "ACTIVE")); } + + public PageResult page(long tenantId, PartnerPageQuery request) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(PartnerEntity::getTenantId, tenantId) + .eq(StringUtils.hasText(request.type()), PartnerEntity::getPartnerType, request.type()) + .eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status()) + .and(StringUtils.hasText(request.keyword()), query -> query + .like(PartnerEntity::getPartnerCode, request.keyword()) + .or().like(PartnerEntity::getPartnerName, request.keyword()) + .or().like(PartnerEntity::getContactName, request.keyword()) + .or().like(PartnerEntity::getContactPhone, request.keyword())) + .orderByAsc(PartnerEntity::getPartnerName) + .orderByAsc(PartnerEntity::getId); + return PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper)); + } + + public PartnerEntity findOwned(long tenantId, long id) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(PartnerEntity::getTenantId, tenantId) + .eq(PartnerEntity::getId, id)); + } + + public boolean existsCode(long tenantId, String code, Long excludedId) { + return mapper.selectCount(new LambdaQueryWrapper() + .eq(PartnerEntity::getTenantId, tenantId) + .eq(PartnerEntity::getPartnerCode, code) + .ne(excludedId != null, PartnerEntity::getId, excludedId)) > 0; + } + + public void insert(PartnerEntity entity) { + mapper.insert(entity); + } + + public void save(PartnerEntity entity) { + mapper.updateById(entity); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java index 0656c1779..1659ecbb7 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -10,6 +10,8 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; import java.util.List; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -32,4 +34,19 @@ class PartnerRepositoryTest { verify(mapper).selectOne(c.capture()); c.getValue().getSqlSegment(); assertThat(c.getValue().getParamNameValuePairs().values()).contains(10002L,9L,"ACTIVE"); } + + @Test void 平台分页强制租户并支持筛选() { + PartnerMapper mapper=mock(PartnerMapper.class); + when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); + + new PartnerRepository(mapper).page(10001L, + new PartnerPageQuery(1, 20, "示例", "CUSTOMER", "ACTIVE")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectPage(any(Page.class), wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .contains(10001L, "CUSTOMER", "ACTIVE", "%示例%"); + } }